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:
2026-10-23 22:07:11 +02:00
parent 9f580fd8c8
commit 9c5f81fd94
17 changed files with 667 additions and 70 deletions
@@ -0,0 +1,44 @@
"""When a user last took a video back to "new"
Revision ID: 0114_video_unmarks
Revises: 0113_takeout_items
Create Date: 2026-10-23
Un-marking or resetting a video leaves no `video_states` row, so this is what tells an import of the
user's YouTube history a video they un-marked here from one never touched.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0114_video_unmarks"
down_revision: str | None = "0113_takeout_items"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"video_unmarks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"video_id",
sa.String(16),
sa.ForeignKey("videos.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "video_id", name="uq_video_unmark"),
)
op.create_index("ix_video_unmarks_video_id", "video_unmarks", ["video_id"])
def downgrade() -> None:
op.drop_index("ix_video_unmarks_video_id", table_name="video_unmarks")
op.drop_table("video_unmarks")
+2 -1
View File
@@ -75,7 +75,8 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
# A Google Takeout file (`reader.takeout`). A real watch history measured 3.7 MB for 9500 views, ~390 # A Google Takeout file (`reader.takeout`). A real watch history measured 3.7 MB for 9500 views, ~390
# bytes a view, so 32 MiB holds ~86k views. YouTube keeps the history 36 months by default on an # bytes a view, so 32 MiB holds ~86k views. YouTube keeps the history 36 months by default on an
# account made since mid-2020 and for good on an older one, so a heavy viewer's can pass 16 MiB. The # account made since mid-2020 and for good on an older one, so a heavy viewer's can pass 16 MiB. The
# file is read an entry at a time (`takeout._json_list`), so it costs its own size, not a tree of it. # file's text is decoded a window at a time and read an entry at a time (`takeout._json_list`), so
# it costs its bytes and a window, not a tree of it; the route takes two at once across every user.
TAKEOUT_MAX_BYTES = 32 * 1024 * 1024 TAKEOUT_MAX_BYTES = 32 * 1024 * 1024
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = { ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
+8
View File
@@ -42,6 +42,14 @@ class RateLimiter:
with self._lock: with self._lock:
return sum(c for t, c in self._hits.get(key, []) if t > cutoff) >= self.max_events return sum(c for t, c in self._hits.get(key, []) if t > cutoff) >= self.max_events
def room(self, key: str) -> float:
"""How much cost ``key`` may still spend in the current window, WITHOUT counting anything —
for work counted only once it has succeeded: ask it first, ``allow`` what was done."""
cutoff = time.monotonic() - self.window
with self._lock:
used = sum(c for t, c in self._hits.get(key, []) if t > cutoff)
return max(0.0, self.max_events - used)
def allow(self, key: str, cost: float = 1) -> bool: def allow(self, key: str, cost: float = 1) -> bool:
"""Record an attempt costing ``cost`` for ``key`` and return whether it is within the limit. """Record an attempt costing ``cost`` for ``key`` and return whether it is within the limit.
+15
View File
@@ -85,6 +85,21 @@ class VideoState(Base, UpdatedAtMixin):
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class VideoUnmark(Base):
"""When the user last took a video back to "new" here — un-marked it, or reset it. That leaves
no `VideoState` (its absence is "new"), so without this row a later import of what they watched
on YouTube could not tell a video they un-marked from one never touched, and marked it watched
again. Kept by user and video; the video's own deletion takes it."""
__tablename__ = "video_unmarks"
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_video_unmark"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
video_id: Mapped[str] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"), index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
class Playlist(Base, TimestampMixin, UpdatedAtMixin): class Playlist(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user named, ordered collection of videos from the shared catalog. """A per-user named, ordered collection of videos from the shared catalog.
+42
View File
@@ -26,6 +26,7 @@ from app.reader.models import (
TakeoutItem, TakeoutItem,
TakeoutKind, TakeoutKind,
VideoState, VideoState,
VideoUnmark,
) )
from app.userscope import owned_by, owned_row from app.userscope import owned_by, owned_row
@@ -376,6 +377,16 @@ def user_tag_link(db: Session, user: User, channel_id: str, tag_id: int) -> Chan
# --- Google Takeout -------------------------------------------------------------------------- # --- Google Takeout --------------------------------------------------------------------------
def _settled_here() -> ColumnElement[bool]:
"""A video a waiting "watched" row may be marked on: one the catalog keeps of its own, not a
discovery video only a search or an explore brought in. A mark is a `VideoState`, which the
cleanup jobs read as a reader keeping it (`kept_by_a_reader`): on one of those it would keep the
video, and its explored channel, for good. Such a row waits until the video is the catalog's own
(its channel subscribed to) — or is gone."""
subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
return and_(Video.via_explore.is_(False), or_(Video.via_search.is_(False), subscribed))
def takeout_watched_ready(db: Session, user: User) -> list[tuple[int, str, datetime, bool]]: def takeout_watched_ready(db: Session, user: User) -> list[tuple[int, str, datetime, bool]]:
"""The user's waiting "watched" rows whose video the catalog knows and has probed for Shorts: """The user's waiting "watched" rows whose video the catalog knows and has probed for Shorts:
(row id, video id, Takeout time, is Short).""" (row id, video id, Takeout time, is Short)."""
@@ -388,6 +399,7 @@ def takeout_watched_ready(db: Session, user: User) -> list[tuple[int, str, datet
TakeoutItem.kind == TakeoutKind.WATCHED, TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.outcome.is_(None), TakeoutItem.outcome.is_(None),
Video.shorts_probed, Video.shorts_probed,
_settled_here(),
) )
).tuples() ).tuples()
) )
@@ -421,6 +433,35 @@ def known_video_ids(db: Session, ids: list[str]) -> set[str]:
return known return known
def unmarked_at(db: Session, user: User, ids: list[str]) -> dict[str, datetime]:
"""When the user last took each of `ids` back to "new" here, for those they ever did."""
out: dict[str, datetime] = {}
for start in range(0, len(ids), _IN_CHUNK):
rows = db.execute(
select(VideoUnmark.video_id, VideoUnmark.at).where(
owned_by(VideoUnmark.user_id, user),
VideoUnmark.video_id.in_(ids[start : start + _IN_CHUNK]),
)
).tuples()
out.update((vid, at) for vid, at in rows)
return out
def in_playlist(db: Session, playlist_id: int, ids: list[str]) -> set[str]:
"""Which of `ids` the playlist already holds."""
held: set[str] = set()
for start in range(0, len(ids), _IN_CHUNK):
chunk = ids[start : start + _IN_CHUNK]
held.update(
db.execute(
select(PlaylistItem.video_id).where(
PlaylistItem.playlist_id == playlist_id, PlaylistItem.video_id.in_(chunk)
)
).scalars()
)
return held
def takeout_rows_besides( def takeout_rows_besides(
db: Session, user: User, kind: TakeoutKind, video_ids: Iterable[str] db: Session, user: User, kind: TakeoutKind, video_ids: Iterable[str]
) -> int: ) -> int:
@@ -476,6 +517,7 @@ def takeout_users_ready(db: Session) -> list[int]:
TakeoutItem.kind == TakeoutKind.WATCHED, TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.outcome.is_(None), TakeoutItem.outcome.is_(None),
Video.shorts_probed, Video.shorts_probed,
_settled_here(),
) )
.distinct() .distinct()
).scalars() ).scalars()
+43 -10
View File
@@ -25,6 +25,7 @@ from app.reader.models import (
TakeoutKind, TakeoutKind,
TakeoutOutcome, TakeoutOutcome,
VideoState, VideoState,
VideoUnmark,
) )
from app.sync.playlists import lock_playlists, recompute_dirty from app.sync.playlists import lock_playlists, recompute_dirty
from app.userscope import owned_by from app.userscope import owned_by
@@ -206,6 +207,7 @@ def unmark(db: Session, user: User, video_id: str) -> None:
the in-progress state, not where the user left off); otherwise the row goes. The player fires the in-progress state, not where the user left off); otherwise the row goes. The player fires
this beside a checkpoint that may delete the same row: the caller's commit then raises this beside a checkpoint that may delete the same row: the caller's commit then raises
`StaleDataError`, which is that lost race and nothing worse.""" `StaleDataError`, which is that lost race and nothing worse."""
lock_takeout(db, user) # an import settling meanwhile reads the un-mark below, or comes first
row = queries.video_state(db, user, video_id) row = queries.video_state(db, user, video_id)
if row is None: if row is None:
return return
@@ -214,9 +216,21 @@ def unmark(db: Session, user: User, video_id: str) -> None:
row.watched_at = None row.watched_at = None
else: else:
db.delete(row) db.delete(row)
_remember_unmark(db, user, video_id)
db.flush() db.flush()
def _remember_unmark(db: Session, user: User, video_id: str) -> None:
"""Note that the user took the video back to "new" now: the state they set leaves no row, and
an import of their YouTube history must not mark it watched again (`apply_takeout_watched`)."""
now = datetime.now(timezone.utc)
db.execute(
pg_insert(VideoUnmark)
.values(user_id=user.id, video_id=video_id, at=now)
.on_conflict_do_update(constraint="uq_video_unmark", set_={"at": now})
)
def mark(db: Session, user: User, video_id: str, status: str) -> None: def mark(db: Session, user: User, video_id: str, status: str) -> None:
""" "watched" or "hidden", as one atomic upsert on (user, video). The player marks "watched" at """ "watched" or "hidden", as one atomic upsert on (user, video). The player marks "watched" at
the end of playback at the instant a checkpoint may delete the in-progress row; a SELECT then the end of playback at the instant a checkpoint may delete the in-progress row; a SELECT then
@@ -241,9 +255,11 @@ def mark(db: Session, user: User, video_id: str, status: str) -> None:
def clear_state(db: Session, user: User, video_id: str) -> None: def clear_state(db: Session, user: User, video_id: str) -> None:
"""The video back to pristine for this user: status, resume point and `watched_at` all go. """The video back to pristine for this user: status, resume point and `watched_at` all go.
Playlist membership lives elsewhere and stays.""" Playlist membership lives elsewhere and stays."""
lock_takeout(db, user) # as `unmark`
row = queries.video_state(db, user, video_id) row = queries.video_state(db, user, video_id)
if row is not None: if row is not None:
db.delete(row) db.delete(row)
_remember_unmark(db, user, video_id)
db.flush() db.flush()
@@ -517,7 +533,8 @@ _TAKEOUT_LOCK_NS = 0x544F
def lock_takeout(db: Session, user: User) -> None: def lock_takeout(db: Session, user: User) -> None:
"""Hold the user's Takeout rows for the rest of the transaction. An upload's staging and an """Hold the user's Takeout rows for the rest of the transaction. An upload's staging and an
import's settling each lock many of the same rows, one statement after another and in no common import's settling each lock many of the same rows, one statement after another and in no common
order, so without one lock over both the two can deadlock.""" order, so without one lock over both the two can deadlock. An un-mark takes it too: the import
reads the un-marks before it marks, and one landing between the two would be overwritten."""
db.execute(select(func.pg_advisory_xact_lock(_TAKEOUT_LOCK_NS, user.id))) db.execute(select(func.pg_advisory_xact_lock(_TAKEOUT_LOCK_NS, user.id)))
@@ -558,15 +575,21 @@ def _settle(db: Session, ids: list[int], outcome: TakeoutOutcome) -> None:
def apply_takeout_watched(db: Session, user: User) -> dict[str, int]: def apply_takeout_watched(db: Session, user: User) -> dict[str, int]:
"""Mark watched what the user watched on YouTube, for the videos the catalog knows and has probed """Mark watched what the user watched on YouTube, for the videos the catalog knows and has probed
for Shorts; the rest wait. `watched_at` is the Takeout time. A Short is not marked (swiping one for Shorts; the rest wait. `watched_at` is the Takeout time. A Short is not marked (swiping one
past is not watching it), and a state the user set here wins: watched or hidden, or anything past is not watching it), and a state the user set here wins: watched or hidden, anything
changed after the Takeout time. The upsert's own condition is that rule, so a state set while changed after the Takeout time, or an un-mark since then (`VideoUnmark`). The upsert's own
the import runs wins too.""" condition is that rule, so a state set while the import runs wins too."""
lock_takeout(db, user) lock_takeout(db, user)
short: list[int] = [] short: list[int] = []
todo: dict[str, tuple[int, datetime]] = {} todo: dict[str, tuple[int, datetime]] = {}
for row_id, video_id, at, is_short in queries.takeout_watched_ready(db, user): ready = queries.takeout_watched_ready(db, user)
unmarked = queries.unmarked_at(db, user, [video_id for _, video_id, _, _ in ready])
kept: list[int] = []
for row_id, video_id, at, is_short in ready:
if is_short: if is_short:
short.append(row_id) short.append(row_id)
elif video_id in unmarked and unmarked[video_id] >= at:
# Un-marked here since they watched it there: theirs, as a state they set is.
kept.append(row_id)
else: else:
todo[video_id] = (row_id, at) todo[video_id] = (row_id, at)
marked: set[str] = set() marked: set[str] = set()
@@ -593,7 +616,7 @@ def apply_takeout_watched(db: Session, user: User) -> dict[str, int]:
).returning(VideoState.video_id) ).returning(VideoState.video_id)
).scalars() ).scalars()
) )
kept = [row_id for vid, (row_id, _) in items if vid not in marked] kept += [row_id for vid, (row_id, _) in items if vid not in marked]
_settle(db, [todo[vid][0] for vid in marked], TakeoutOutcome.WATCHED) _settle(db, [todo[vid][0] for vid in marked], TakeoutOutcome.WATCHED)
_settle(db, kept, TakeoutOutcome.KEPT) _settle(db, kept, TakeoutOutcome.KEPT)
_settle(db, short, TakeoutOutcome.SHORT) _settle(db, short, TakeoutOutcome.SHORT)
@@ -610,17 +633,27 @@ def apply_takeout_watch_later(
if not rows: if not rows:
return {} return {}
lock_takeout(db, user) lock_takeout(db, user)
known = queries.known_video_ids(db, [vid for _, vid in rows]) ids = [vid for _, vid in rows]
known = queries.known_video_ids(db, ids)
pl = watch_later(db, user) pl = watch_later(db, user)
# What add_item does a video at a time, for the whole list at once: one presence read and one
# insert, not four statements a video (a full list is thousands). Locked before the read, as there.
next_position = next_item_position(db, pl.id)
held = queries.in_playlist(db, pl.id, [vid for vid in ids if vid in known])
done: dict[TakeoutOutcome, list[int]] = {o: [] for o in TakeoutOutcome} done: dict[TakeoutOutcome, list[int]] = {o: [] for o in TakeoutOutcome}
for row_id, vid in rows: for row_id, vid in rows:
if vid not in known: if vid not in known:
if vid in asked: if vid in asked:
done[TakeoutOutcome.MISSING].append(row_id) done[TakeoutOutcome.MISSING].append(row_id)
elif add_item(db, pl, vid, mark_dirty=False): elif vid in held:
done[TakeoutOutcome.ADDED].append(row_id)
else:
done[TakeoutOutcome.PRESENT].append(row_id) done[TakeoutOutcome.PRESENT].append(row_id)
else:
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=next_position))
next_position += 1
held.add(vid)
done[TakeoutOutcome.ADDED].append(row_id)
if done[TakeoutOutcome.ADDED]:
touch(pl)
for outcome, ids in done.items(): for outcome, ids in done.items():
_settle(db, ids, outcome) _settle(db, ids, outcome)
db.flush() db.flush()
+122 -34
View File
@@ -15,6 +15,7 @@ Ads". The title is in the account's language, so nothing here reads it. A Short
header row (in the account's language) and `<id>,<ISO time added>` rows. header row (in the account's language) and `<id>,<ISO time added>` rows.
""" """
import codecs
import csv import csv
import io import io
import json import json
@@ -28,7 +29,7 @@ from itertools import chain
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app import logs, quota
from app.bodylimit import TAKEOUT_MAX_BYTES from app.bodylimit import TAKEOUT_MAX_BYTES
from app.db import SessionLocal from app.db import SessionLocal
from app.models import User from app.models import User
@@ -39,6 +40,8 @@ from app.sync.runner import get_service_user
from app.sync.videos import ensure_videos from app.sync.videos import ensure_videos
from app.youtube.client import YouTubeClient from app.youtube.client import YouTubeClient
log = logs.get_logger(__name__)
# The largest file the upload takes (its edge cap and the route's own are one number). # The largest file the upload takes (its edge cap and the route's own are one number).
MAX_BYTES = TAKEOUT_MAX_BYTES MAX_BYTES = TAKEOUT_MAX_BYTES
# The most videos a Watch later file may hold: YouTube's own cap on one playlist. Each unknown one is # The most videos a Watch later file may hold: YouTube's own cap on one playlist. Each unknown one is
@@ -150,44 +153,113 @@ def _skip(text: str, at: int) -> int:
return at return at
def _json_list(text: str) -> Iterator[object]: # How much of the file is decoded at a time. Decoded whole, the text costs up to four bytes a
"""The entries of a JSON list, one at a time: the whole tree is never built, so a file of a # character (one emoji in a title is enough), several times the file on top of its bytes.
million tiny entries costs its text and one entry, not a million dicts at once.""" _WINDOW = 1024 * 1024
class _Window:
"""The file's text, decoded a window at a time: `text` holds what is not read yet from `at` on.
The bytes are the caller's, so only the window is this reader's memory."""
def __init__(self, body: bytes, start: int):
self._body, self._next = body, start
self._decoder = codecs.getincrementaldecoder("utf-8")() # strict: bad UTF-8 raises
self.text, self.at = "", 0
@property
def done(self) -> bool:
return self._next >= len(self._body)
def more(self, at_least: int = 0) -> None:
"""Decode the next window (at least `at_least` bytes), dropping what is read already."""
self.text, self.at = self.text[self.at :], 0
size = max(_WINDOW, at_least)
chunk = self._body[self._next : self._next + size]
self._next += len(chunk)
self.text += self._decoder.decode(chunk, final=self.done)
def skip(self) -> None:
"""Past the whitespace, reading on as far as it goes."""
while True:
self.at = _skip(self.text, self.at)
if self.at < len(self.text) or self.done:
return
self.more()
def startswith(self, char: str) -> bool:
self.skip()
return self.text.startswith(char, self.at)
def _decode(decoder: json.JSONDecoder, text: str, at: int) -> tuple[object, int] | None:
"""The JSON value at `at` and where it ends, or None where none reads (yet)."""
try:
return decoder.raw_decode(text, at)
# silent-ok: not a failure here — the caller reads on, or refuses the file once it has all.
except json.JSONDecodeError:
return None
def _json_list(body: bytes, start: int) -> Iterator[object]:
"""The entries of the JSON list at byte `start` (its "["), one at a time: the whole tree is never
built, nor the whole text, so a file of a million tiny entries costs a window and one entry, not
a million dicts at once."""
decoder = json.JSONDecoder() decoder = json.JSONDecoder()
at = _skip(text, _skip(text, 0) + 1) # past the "[" the caller saw window = _Window(body, start + 1) # past the "[" the caller saw
if text.startswith("]", at): if window.startswith("]"):
at += 1 window.at += 1
else: else:
while True: while True:
entry, at = decoder.raw_decode(text, at) window.skip()
read = _decode(decoder, window.text, window.at)
# An entry cut by the window's edge fails, or (a number) reads short: read on and try
# again, with a window twice what is left, so one entry larger than any window is read
# in a few tries, not one a window.
if read is None or (read[1] == len(window.text) and not window.done):
if window.done:
raise ValueError("not a JSON list")
window.more(2 * (len(window.text) - window.at))
continue
entry, window.at = read
yield entry yield entry
at = _skip(text, at) if window.startswith(","):
if text.startswith(",", at): window.at += 1
at = _skip(text, at + 1) elif window.startswith("]"):
elif text.startswith("]", at): window.at += 1
at += 1
break break
else: else:
raise ValueError("not a JSON list") raise ValueError("not a JSON list")
if _skip(text, at) != len(text): window.skip()
if window.at != len(window.text):
raise ValueError("text after the JSON list") raise ValueError("text after the JSON list")
# The largest Watch later file read: YouTube's longest list is some 200 KB of rows, so a file ten
# times that is not one — and, unlike the history, it is decoded whole.
CSV_MAX_BYTES = 2 * 1024 * 1024
_BOM = codecs.BOM_UTF8
def parse(body: bytes) -> Parsed: def parse(body: bytes) -> Parsed:
"""Read an uploaded Takeout file: the watch history (JSON) or the Watch later list (csv), told """Read an uploaded Takeout file: the watch history (JSON) or the Watch later list (csv), told
apart by their content, not their name. Raises `TakeoutError` for anything else, and for a file apart by their content, not their name. Raises `TakeoutError` for anything else, and for a file
with nothing to import in it.""" with nothing to import in it."""
try: start = len(_BOM) if body.startswith(_BOM) else 0
text = body.decode("utf-8-sig") while start < len(body) and body[start] in b" \t\n\r\f\v":
except UnicodeDecodeError: start += 1
raise TakeoutError("not_takeout") from None first = body[start : start + 1]
stripped = text.lstrip() if first in (b"{", b"<"):
if stripped.startswith(("{", "<")):
# Another JSON file of the export, or the history's HTML variant. # Another JSON file of the export, or the history's HTML variant.
raise TakeoutError("not_takeout") raise TakeoutError("not_takeout")
if first != b"[" and len(body) - start > CSV_MAX_BYTES:
raise TakeoutError("too_many")
try: try:
# A bad UTF-8 sequence is a ValueError too (UnicodeDecodeError), read below as not a file.
parsed = ( parsed = (
_history(_json_list(stripped)) if stripped.startswith("[") else _watch_later(stripped) _history(_json_list(body, start))
if first == b"["
else _watch_later(body[start:].decode("utf-8"))
) )
# A file the import cannot read, however it fails: bad JSON (a ValueError, as is a number too # A file the import cannot read, however it fails: bad JSON (a ValueError, as is a number too
# long to convert), nesting deeper than the decoder recurses, a csv field past csv's limit. # long to convert), nesting deeper than the decoder recurses, a csv field past csv's limit.
@@ -200,10 +272,10 @@ def parse(body: bytes) -> Parsed:
return parsed return parsed
# One import per user at a time: a second file uploaded while the first one's run is under way # One import per user at a time: a second file uploaded while the first one's run is under way runs
# starts a run of its own (the first one's id has left the scheduler), and the two would settle the # after it (the scheduler's ad-hoc queue), but the Shorts job settles the same users' rows on a
# same rows. The second waits, then takes what the first left. One API process runs the scheduler, # thread of its own, and the two would settle the same rows. One API process runs the scheduler, so a
# so a lock in this module holds it (as the channel backfill's does). # lock in this module holds it (as the channel backfill's does).
_user_locks: dict[int, threading.Lock] = {} _user_locks: dict[int, threading.Lock] = {}
_user_locks_guard = threading.Lock() _user_locks_guard = threading.Lock()
@@ -266,11 +338,14 @@ def _put_in_watch_later(db: Session, user: User) -> dict:
if ( if (
fetcher is not None fetcher is not None
and not quota.backfill_reserve_reached() and not quota.backfill_reserve_reached()
and _lookups.allow(str(user.id), cost=len(ask)) and _lookups.room(str(user.id)) >= len(ask)
): ):
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP):
with YouTubeClient(db, fetcher) as yt: with YouTubeClient(db, fetcher) as yt:
ensure_videos(db, yt, ask) ensure_videos(db, yt, ask)
# Counted once done: a lookup that failed spent none of the share (one run per user at a
# time, so the room asked above is still there).
_lookups.allow(str(user.id), cost=len(ask))
asked = set(ask) asked = set(ask)
# Only the rows read above: one a second upload staged meanwhile was never asked about. # Only the rows read above: one a second upload staged meanwhile was never asked about.
return service.apply_takeout_watch_later(db, user, waiting, asked) return service.apply_takeout_watch_later(db, user, waiting, asked)
@@ -301,23 +376,36 @@ def apply_waiting(db: Session) -> dict:
search's), and the Watch later videos a run could not put in yet. Run after each run of the search's), and the Watch later videos a run could not put in yet. Run after each run of the
`shorts` job, which is what clears the most of them. Returns its counts, or an empty dict when `shorts` job, which is what clears the most of them. Returns its counts, or an empty dict when
nothing was waiting that could move.""" nothing was waiting that could move."""
marked = added = 0 marked = added = failed = 0
ready = set(queries.takeout_users_ready(db)) ready = set(queries.takeout_users_ready(db))
later = set(queries.takeout_users_watch_later_waiting(db)) later = set(queries.takeout_users_watch_later_waiting(db))
for user_id in sorted(ready | later): for user_id in sorted(ready | later):
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None: if user is None:
continue continue
with _one_import(user_id): # One user's failure (a lookup YouTube refused) is theirs: it neither fails the job that
if user_id in ready: # runs this nor keeps the users after them waiting. What it did not settle waits.
marked += service.apply_takeout_watched(db, user)["watched"] try:
db.commit() with _one_import(user_id):
if user_id in later: if user_id in ready:
added += _put_in_watch_later(db, user).get("watch_later_added", 0) marked += service.apply_takeout_watched(db, user)["watched"]
db.commit() db.commit()
if user_id in later:
added += _put_in_watch_later(db, user).get("watch_later_added", 0)
db.commit()
except Exception:
db.rollback()
failed += 1
log.exception(
"Takeout settling failed for user %s",
user_id,
extra=logs.event("takeout.settle.failed", user_id=user_id),
)
out = {} out = {}
if ready: if ready:
out["takeout_watched"] = marked out["takeout_watched"] = marked
if added: if added:
out["takeout_watch_later_added"] = added out["takeout_watch_later_added"] = added
if failed:
out["takeout_failed"] = failed
return out return out
+19 -10
View File
@@ -309,38 +309,47 @@ def get_video_detail(
# --- Google Takeout ------------------------------------------------------------------------------ # --- Google Takeout ------------------------------------------------------------------------------
# One upload at a time per user, and ten an hour: a file is held whole while it is read, so these # One upload at a time per user, ten an hour, and two at once across every user: a file's bytes are
# bound what one account can make the process hold. The section uploads its files one after another. # held whole while it is read (its text a window at a time), so these bound what the accounts can
# make the process hold. The section uploads its files one after another.
_takeout_limiter = RateLimiter(max_events=10, window_seconds=3600) _takeout_limiter = RateLimiter(max_events=10, window_seconds=3600)
_takeout_uploading: set[int] = set() _takeout_uploading: set[int] = set()
_takeout_uploading_guard = threading.Lock() _takeout_uploading_guard = threading.Lock()
_TAKEOUT_AT_ONCE = 2
async def _takeout_body( async def _takeout_body(
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db) request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> AsyncIterator[bytes]: ) -> AsyncIterator[bytes]:
# The sign-in check has run, and it was a SELECT: hand the connection back before a file of # The sign-in check has run, and it was a SELECT: hand the connection back before a file of
# megabytes arrives, so a slow upload does not hold one `idle in transaction`. # megabytes arrives, so a slow upload does not hold one `idle in transaction`. The user's id is
# taken first: the release expires the user, and reading it after would reload it, taking a
# connection straight back out.
uid = user.id
release_read_connection(db) release_read_connection(db)
with _takeout_uploading_guard: with _takeout_uploading_guard:
busy = user.id in _takeout_uploading busy = uid in _takeout_uploading
if not busy: full = not busy and len(_takeout_uploading) >= _TAKEOUT_AT_ONCE
_takeout_uploading.add(user.id) taken = not busy and not full
if busy or not _takeout_limiter.allow(str(user.id)): if taken:
if not busy: _takeout_uploading.add(uid)
if not taken or not _takeout_limiter.allow(str(uid)):
if taken:
with _takeout_uploading_guard: with _takeout_uploading_guard:
_takeout_uploading.discard(user.id) _takeout_uploading.discard(uid)
# Read to the end before answering: refused mid-upload, the browser reports a network # Read to the end before answering: refused mid-upload, the browser reports a network
# failure instead of the refusal (capped_stream_to says why). # failure instead of the refusal (capped_stream_to says why).
await drain_body(request) await drain_body(request)
if busy: if busy:
raise refused(429, "takeout_busy") raise refused(429, "takeout_busy")
if full:
raise refused(429, "takeout_server_busy")
raise refused(429, "takeout_cooldown") raise refused(429, "takeout_cooldown")
try: try:
yield await capped_body(request, takeout.MAX_BYTES) yield await capped_body(request, takeout.MAX_BYTES)
finally: finally:
with _takeout_uploading_guard: with _takeout_uploading_guard:
_takeout_uploading.discard(user.id) _takeout_uploading.discard(uid)
@router.post("/takeout") @router.post("/takeout")
+63 -10
View File
@@ -637,6 +637,15 @@ def ad_hoc_target_title(db, job: str, target: str) -> str | None:
# run row is still the previous run's, so a page watching it asks `ad_hoc_queued` too. # run row is still the previous run's, so a page watching it asks `ad_hoc_queued` too.
_queued: set[str] = set() _queued: set[str] = set()
_queued_guard = threading.Lock() _queued_guard = threading.Lock()
# What the scheduler is told to run is only the key: the request itself waits here, the newest one
# per key, until a fire of that key takes it. A request made while its key's run is under way is so
# kept for the run after it, which that run asks for as it ends (`_running`): the scheduler would
# drop a second fire of an id whose run has not returned (one instance per job id).
_pending: dict[str, tuple[Callable[[], dict], int, bool]] = {}
_running: set[str] = set()
# Two at once, for the moment between a run letting go of its key and its thread returning: a fire
# then is what takes the next request, and is not dropped.
_AD_HOC_INSTANCES = 2
def ad_hoc_queued(job: str, target: str) -> bool: def ad_hoc_queued(job: str, target: str) -> bool:
@@ -650,37 +659,77 @@ def run_ad_hoc(
) -> None: ) -> None:
"""Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its """Run `fn` now as `actor_id`'s run of `job` on `target`, a one-shot job on the scheduler. Its
own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so own exclusion (if any) lives in `fn`, which says so in its result; one id per job and target, so
a second request before the first has even started is that same run. `notify` posts its outcome a second request before the first has even started is that same run, and one made while it runs
to the user's inbox, for work whose page does not watch it run.""" is the run after it. `notify` posts its outcome to the user's inbox, for work whose page does not
watch it run."""
if job not in AD_HOC_JOBS: if job not in AD_HOC_JOBS:
raise KeyError(job) # an unnamed job would show on the page as its bare id raise KeyError(job) # an unnamed job would show on the page as its bare id
sched = _scheduler if _scheduler is None:
if sched is None:
# Only between shutdown and exit: start_scheduler runs at every API startup. # Only between shutdown and exit: start_scheduler runs at every API startup.
raise RuntimeError("the scheduler is not running") raise RuntimeError("the scheduler is not running")
key = f"{job}@{target}" key = f"{job}@{target}"
with _queued_guard: with _queued_guard:
_pending[key] = (fn, actor_id, notify)
_queued.add(key) _queued.add(key)
running = key in _running
if running:
log.info("ad-hoc %s for %s runs again once the run under way ends", job, target)
return
_fire(job, target)
def _fire(job: str, target: str) -> None:
"""Add the one-shot that takes `job@target`'s pending request. Raises (the request dropped) only
when the scheduler cannot take it."""
key = f"{job}@{target}"
sched = _scheduler
try: try:
if sched is None:
raise RuntimeError("the scheduler is not running")
sched.add_job( sched.add_job(
_ad_hoc, _ad_hoc_fired,
"date", "date",
args=(job, fn, actor_id, target, notify), args=(job, target),
id=key, id=key,
misfire_grace_time=None, misfire_grace_time=None,
max_instances=_AD_HOC_INSTANCES,
) )
# silent-ok: not a failure — the run for this target is already queued, and it does this work. # silent-ok: not a failure — a fire of this key is already queued, and it takes this request.
except ConflictingIdError: except ConflictingIdError:
log.info("ad-hoc %s for %s already queued", job, target) log.info("ad-hoc %s for %s already queued", job, target)
except Exception: except Exception:
with _queued_guard: with _queued_guard:
_pending.pop(key, None)
_queued.discard(key) _queued.discard(key)
raise raise
def _ad_hoc_fired(job: str, target: str) -> None:
key = f"{job}@{target}"
with _queued_guard:
request = _pending.pop(key, None)
if request is None:
return # an earlier fire took it
_running.add(key)
fn, actor_id, notify = request
try:
_ad_hoc(job, fn, actor_id, target, notify)
finally:
with _queued_guard:
_running.discard(key)
again = key in _pending
if again:
try:
_fire(job, target)
except Exception:
# The request made meanwhile is lost with the scheduler: shut down, nothing runs.
log.exception("ad-hoc %s for %s not run again", job, target)
def _ad_hoc( def _ad_hoc(
job: str, fn: Callable[[], dict], actor_id: int, target: str, notify: bool = False job: str, fn: Callable[[], dict], actor_id: int, target: str, notify: bool = False
) -> None: ) -> None:
key = f"{job}@{target}"
try: try:
run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target) run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target)
except Exception: except Exception:
@@ -688,9 +737,11 @@ def _ad_hoc(
log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target) log.exception("ad-hoc %s for %s not started: its run could not be recorded", job, target)
return return
finally: finally:
# Recorded (or never will be): the run row now speaks for it. # Recorded (or never will be): the run row now speaks for it — unless another request came
# meanwhile, which is still queued.
with _queued_guard: with _queued_guard:
_queued.discard(f"{job}@{target}") if key not in _pending:
_queued.discard(key)
_opened(run_id, job) _opened(run_id, job)
try: try:
_ad_hoc_work(job, fn, run_id, actor_id if notify else None) _ad_hoc_work(job, fn, run_id, actor_id if notify else None)
@@ -765,4 +816,6 @@ def shutdown_scheduler() -> None:
_scheduler.shutdown(wait=False) _scheduler.shutdown(wait=False)
_scheduler = None _scheduler = None
with _queued_guard: with _queued_guard:
_queued.clear() # what it held will not run # What it held will not run.
_queued.clear()
_pending.clear()
+12
View File
@@ -327,3 +327,15 @@ def _reset_log_erasures():
from app import logs from app import logs
logs._erased.clear() logs._erased.clear()
@pytest.fixture(autouse=True)
def _reset_ad_hoc_queue():
"""The scheduler's ad-hoc queue is module-level too: a request a test left pending would read as
a later test's user's import still queued (ids restart from 1)."""
from app import scheduler
with scheduler._queued_guard:
scheduler._queued.clear()
scheduler._pending.clear()
scheduler._running.clear()
+35
View File
@@ -6,6 +6,7 @@ through (at the top of _job): holding _activity_lock across the check-and-set me
caller can hold a job's "running" claim at a time, and it's claimable again once released.""" caller can hold a job's "running" claim at a time, and it's claimable again once released."""
import logging import logging
import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -426,6 +427,40 @@ def test_a_second_ad_hoc_request_before_the_first_starts_is_that_same_run(
live_scheduler.remove_all_jobs() # never run: a run finishing after the test would land in the next live_scheduler.remove_all_jobs() # never run: a run finishing after the test would land in the next
def test_a_request_made_while_its_run_is_under_way_is_the_run_after_it(
db, make_user, live_scheduler
):
"""The scheduler drops a second fire of an id whose run has not returned: the second Takeout file
of a pair, uploaded while the first one's import ran, was never imported, and its key stayed
queued until a restart (the page spun on "Processing…" for good)."""
user = make_user("again@example.com")
first_runs, release = threading.Event(), threading.Event()
calls = []
def first():
first_runs.set()
assert release.wait(10)
calls.append("first")
return {"n": 1}
def second():
calls.append("second")
return {"n": 2}
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, first, actor_id=user.id, target="UC_again")
assert first_runs.wait(10)
scheduler.run_ad_hoc(scheduler.CHANNEL_BACKFILL, second, actor_id=user.id, target="UC_again")
assert scheduler.ad_hoc_queued(scheduler.CHANNEL_BACKFILL, "UC_again")
release.set()
deadline = time.monotonic() + 10
while time.monotonic() < deadline and len(_finished(db, "channel_backfill")) < 2:
time.sleep(0.05)
assert calls == ["first", "second"]
assert sorted(r.result["n"] for r in _finished(db, "channel_backfill")) == [1, 2]
assert not scheduler.ad_hoc_queued(scheduler.CHANNEL_BACKFILL, "UC_again")
def test_a_manual_run_leaves_no_actor_behind_on_its_thread(db, make_user): def test_a_manual_run_leaves_no_actor_behind_on_its_thread(db, make_user):
"""The scheduler's pool threads are reused: an actor a manual run left set made the next """The scheduler's pool threads are reused: an actor a manual run left set made the next
scheduled run on that thread an admin's "manual" run, with a notice in their inbox.""" scheduled run on that thread an admin's "manual" run, with a notice in their inbox."""
+255 -3
View File
@@ -7,12 +7,15 @@ import tracemalloc
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import pytest import pytest
from fastapi import Depends
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
from app import jobruns, quota, scheduler from app import jobruns, quota, scheduler
from app.db import SessionLocal from app.auth import current_user
from app.catalog.models import Channel, Video from app.db import SessionLocal, get_db
from app.catalog.models import Channel, Subscription, Video
from app.models import JobRun, Notification, User from app.models import JobRun, Notification, User
from app.runstatus import RunStatus, RunTrigger from app.runstatus import RunStatus, RunTrigger
from app.reader import queries, service, takeout 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" 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): def test_the_id_lists_are_asked_in_chunks(db, make_user, monkeypatch):
user = make_user("chunks@example.com") user = make_user("chunks@example.com")
ids = [f"chunk{n:06d}" for n in range(5)] 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) 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): def test_a_second_upload_keeps_the_later_time_and_what_the_import_settled(db, make_user):
user = make_user("again@example.com") user = make_user("again@example.com")
_video(db, "plain000000") _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] 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( def test_the_job_fetches_the_unknown_watch_later_videos_and_says_what_waits(
db, make_user, monkeypatch 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) == {} 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( def test_a_watch_later_row_staged_while_youtube_is_asked_is_left_for_the_next_run(
db, make_user, monkeypatch 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( "watch later": lambda s, u: service.apply_takeout_watch_later(
s, u, [(1, "aaaaaaaaaaa")], set() 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: with SessionLocal() as holder:
service.lock_takeout(holder, holder.get(User, user.id)) 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 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( def test_uploads_past_the_hourly_limit_are_refused_after_their_body_arrived(
db, make_user, as_user, queued 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} 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( def test_an_upload_that_would_keep_too_many_videos_is_refused(
db, make_user, as_user, queued, monkeypatch 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 scheduler.ad_hoc_queued("takeout_import", target)
assert as_user(user).get("/api/takeout").json()["queued"] is True 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 not scheduler.ad_hoc_queued("takeout_import", target)
assert as_user(user).get("/api/takeout").json()["queued"] is False assert as_user(user).get("/api/takeout").json()["queued"] is False
assert added == [f"takeout_import@{target}"] 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 ---------------------------------------------------------------------- # --- 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) == {} 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): def test_the_shorts_job_settles_the_waiting_rows_its_probe_made_ready(db, make_user):
user = make_user("job-shorts@example.com") user = make_user("job-shorts@example.com")
_video(db, "ready000000") # probed already: the job's probe has nothing left to do _video(db, "ready000000") # probed already: the job's probe has nothing left to do
+1
View File
@@ -45,6 +45,7 @@ UNIT_MODELS = {
"SavedView", "SavedView",
"ComfortPin", "ComfortPin",
"TakeoutItem", "TakeoutItem",
"VideoUnmark",
"Tag", "Tag",
"ChannelTag", "ChannelTag",
}, },
+1
View File
@@ -254,6 +254,7 @@
"takeout_unreadable": "This isn't a Takeout file the import reads. Upload watch-history.json (JSON format) or Watch later-videos.csv.", "takeout_unreadable": "This isn't a Takeout file the import reads. Upload watch-history.json (JSON format) or Watch later-videos.csv.",
"takeout_no_videos": "This file has no videos to import", "takeout_no_videos": "This file has no videos to import",
"takeout_busy": "Another of your files is still being uploaded — wait for it to finish", "takeout_busy": "Another of your files is still being uploaded — wait for it to finish",
"takeout_server_busy": "Other imports are being received right now — try again in a minute",
"takeout_cooldown": "You have uploaded many files in the last hour — try again later", "takeout_cooldown": "You have uploaded many files in the last hour — try again later",
"takeout_too_many": "That would keep more than {{limit, number}} videos from your export — more than a real one holds", "takeout_too_many": "That would keep more than {{limit, number}} videos from your export — more than a real one holds",
"takeout_list_too_long": "This list holds more than {{limit, number}} videos — more than a YouTube list can" "takeout_list_too_long": "This list holds more than {{limit, number}} videos — more than a YouTube list can"
+2 -1
View File
@@ -188,7 +188,8 @@
"watch_later_missing": "no longer on YouTube", "watch_later_missing": "no longer on YouTube",
"watch_later_waiting": "Watch later waiting", "watch_later_waiting": "Watch later waiting",
"takeout_watched": "marked watched from Takeout", "takeout_watched": "marked watched from Takeout",
"takeout_watch_later_added": "put into Watch later from Takeout" "takeout_watch_later_added": "put into Watch later from Takeout",
"takeout_failed": "Takeout users left for the next run after an error"
}, },
"skip": { "skip": {
"disabled": "disabled", "disabled": "disabled",
+1
View File
@@ -254,6 +254,7 @@
"takeout_unreadable": "Ez nem olyan Takeout-fájl, amit az import olvas. A watch-history.json (JSON formátum) vagy a Watch later-videos.csv fájlt töltsd fel.", "takeout_unreadable": "Ez nem olyan Takeout-fájl, amit az import olvas. A watch-history.json (JSON formátum) vagy a Watch later-videos.csv fájlt töltsd fel.",
"takeout_no_videos": "Ebben a fájlban nincs importálható videó", "takeout_no_videos": "Ebben a fájlban nincs importálható videó",
"takeout_busy": "Egy másik fájlod feltöltése még folyik — várd meg, amíg befejeződik", "takeout_busy": "Egy másik fájlod feltöltése még folyik — várd meg, amíg befejeződik",
"takeout_server_busy": "Épp más importok érkeznek — próbáld újra egy perc múlva",
"takeout_cooldown": "Az elmúlt órában sok fájlt töltöttél fel — próbáld újra később", "takeout_cooldown": "Az elmúlt órában sok fájlt töltöttél fel — próbáld újra később",
"takeout_too_many": "Így több mint {{limit, number}} videót őriznénk meg az exportodból — többet, mint amennyi egy valódiban van", "takeout_too_many": "Így több mint {{limit, number}} videót őriznénk meg az exportodból — többet, mint amennyi egy valódiban van",
"takeout_list_too_long": "Ez a lista több mint {{limit, number}} videót tartalmaz — többet, mint amennyi egy YouTube-listán lehet" "takeout_list_too_long": "Ez a lista több mint {{limit, number}} videót tartalmaz — többet, mint amennyi egy YouTube-listán lehet"
+2 -1
View File
@@ -188,7 +188,8 @@
"watch_later_missing": "már nincs a YouTube-on", "watch_later_missing": "már nincs a YouTube-on",
"watch_later_waiting": "a „Megnézem később” listára vár", "watch_later_waiting": "a „Megnézem később” listára vár",
"takeout_watched": "Takeoutból megnézettre jelölve", "takeout_watched": "Takeoutból megnézettre jelölve",
"takeout_watch_later_added": "Takeoutból a „Megnézem később” listába téve" "takeout_watch_later_added": "Takeoutból a „Megnézem később” listába téve",
"takeout_failed": "hiba miatt a következő futásra maradt Takeout-felhasználók"
}, },
"skip": { "skip": {
"disabled": "kikapcsolva", "disabled": "kikapcsolva",