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:
@@ -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")
|
||||
@@ -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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
|
||||
|
||||
@@ -42,6 +42,14 @@ class RateLimiter:
|
||||
with self._lock:
|
||||
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:
|
||||
"""Record an attempt costing ``cost`` for ``key`` and return whether it is within the limit.
|
||||
|
||||
|
||||
@@ -85,6 +85,21 @@ class VideoState(Base, UpdatedAtMixin):
|
||||
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):
|
||||
"""A per-user named, ordered collection of videos from the shared catalog.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.reader.models import (
|
||||
TakeoutItem,
|
||||
TakeoutKind,
|
||||
VideoState,
|
||||
VideoUnmark,
|
||||
)
|
||||
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 --------------------------------------------------------------------------
|
||||
|
||||
|
||||
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]]:
|
||||
"""The user's waiting "watched" rows whose video the catalog knows and has probed for Shorts:
|
||||
(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.outcome.is_(None),
|
||||
Video.shorts_probed,
|
||||
_settled_here(),
|
||||
)
|
||||
).tuples()
|
||||
)
|
||||
@@ -421,6 +433,35 @@ def known_video_ids(db: Session, ids: list[str]) -> set[str]:
|
||||
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(
|
||||
db: Session, user: User, kind: TakeoutKind, video_ids: Iterable[str]
|
||||
) -> int:
|
||||
@@ -476,6 +517,7 @@ def takeout_users_ready(db: Session) -> list[int]:
|
||||
TakeoutItem.kind == TakeoutKind.WATCHED,
|
||||
TakeoutItem.outcome.is_(None),
|
||||
Video.shorts_probed,
|
||||
_settled_here(),
|
||||
)
|
||||
.distinct()
|
||||
).scalars()
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.reader.models import (
|
||||
TakeoutKind,
|
||||
TakeoutOutcome,
|
||||
VideoState,
|
||||
VideoUnmark,
|
||||
)
|
||||
from app.sync.playlists import lock_playlists, recompute_dirty
|
||||
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
|
||||
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."""
|
||||
lock_takeout(db, user) # an import settling meanwhile reads the un-mark below, or comes first
|
||||
row = queries.video_state(db, user, video_id)
|
||||
if row is None:
|
||||
return
|
||||
@@ -214,9 +216,21 @@ def unmark(db: Session, user: User, video_id: str) -> None:
|
||||
row.watched_at = None
|
||||
else:
|
||||
db.delete(row)
|
||||
_remember_unmark(db, user, video_id)
|
||||
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:
|
||||
""" "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
|
||||
@@ -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:
|
||||
"""The video back to pristine for this user: status, resume point and `watched_at` all go.
|
||||
Playlist membership lives elsewhere and stays."""
|
||||
lock_takeout(db, user) # as `unmark`
|
||||
row = queries.video_state(db, user, video_id)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
_remember_unmark(db, user, video_id)
|
||||
db.flush()
|
||||
|
||||
|
||||
@@ -517,7 +533,8 @@ _TAKEOUT_LOCK_NS = 0x544F
|
||||
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
|
||||
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)))
|
||||
|
||||
|
||||
@@ -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]:
|
||||
"""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
|
||||
past is not watching it), and a state the user set here wins: watched or hidden, or anything
|
||||
changed after the Takeout time. The upsert's own condition is that rule, so a state set while
|
||||
the import runs wins too."""
|
||||
past is not watching it), and a state the user set here wins: watched or hidden, anything
|
||||
changed after the Takeout time, or an un-mark since then (`VideoUnmark`). The upsert's own
|
||||
condition is that rule, so a state set while the import runs wins too."""
|
||||
lock_takeout(db, user)
|
||||
short: list[int] = []
|
||||
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:
|
||||
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:
|
||||
todo[video_id] = (row_id, at)
|
||||
marked: set[str] = set()
|
||||
@@ -593,7 +616,7 @@ def apply_takeout_watched(db: Session, user: User) -> dict[str, int]:
|
||||
).returning(VideoState.video_id)
|
||||
).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, kept, TakeoutOutcome.KEPT)
|
||||
_settle(db, short, TakeoutOutcome.SHORT)
|
||||
@@ -610,17 +633,27 @@ def apply_takeout_watch_later(
|
||||
if not rows:
|
||||
return {}
|
||||
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)
|
||||
# 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}
|
||||
for row_id, vid in rows:
|
||||
if vid not in known:
|
||||
if vid in asked:
|
||||
done[TakeoutOutcome.MISSING].append(row_id)
|
||||
elif add_item(db, pl, vid, mark_dirty=False):
|
||||
done[TakeoutOutcome.ADDED].append(row_id)
|
||||
else:
|
||||
elif vid in held:
|
||||
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():
|
||||
_settle(db, ids, outcome)
|
||||
db.flush()
|
||||
|
||||
+115
-27
@@ -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.
|
||||
"""
|
||||
|
||||
import codecs
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -28,7 +29,7 @@ from itertools import chain
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota
|
||||
from app import logs, quota
|
||||
from app.bodylimit import TAKEOUT_MAX_BYTES
|
||||
from app.db import SessionLocal
|
||||
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.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).
|
||||
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
|
||||
@@ -150,44 +153,113 @@ def _skip(text: str, at: int) -> int:
|
||||
return at
|
||||
|
||||
|
||||
def _json_list(text: str) -> Iterator[object]:
|
||||
"""The entries of a JSON list, one at a time: the whole tree is never built, so a file of a
|
||||
million tiny entries costs its text and one entry, not a million dicts at once."""
|
||||
# How much of the file is decoded at a time. Decoded whole, the text costs up to four bytes a
|
||||
# character (one emoji in a title is enough), several times the file on top of its bytes.
|
||||
_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()
|
||||
at = _skip(text, _skip(text, 0) + 1) # past the "[" the caller saw
|
||||
if text.startswith("]", at):
|
||||
at += 1
|
||||
window = _Window(body, start + 1) # past the "[" the caller saw
|
||||
if window.startswith("]"):
|
||||
window.at += 1
|
||||
else:
|
||||
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
|
||||
at = _skip(text, at)
|
||||
if text.startswith(",", at):
|
||||
at = _skip(text, at + 1)
|
||||
elif text.startswith("]", at):
|
||||
at += 1
|
||||
if window.startswith(","):
|
||||
window.at += 1
|
||||
elif window.startswith("]"):
|
||||
window.at += 1
|
||||
break
|
||||
else:
|
||||
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")
|
||||
|
||||
|
||||
# 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:
|
||||
"""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
|
||||
with nothing to import in it."""
|
||||
try:
|
||||
text = body.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
raise TakeoutError("not_takeout") from None
|
||||
stripped = text.lstrip()
|
||||
if stripped.startswith(("{", "<")):
|
||||
start = len(_BOM) if body.startswith(_BOM) else 0
|
||||
while start < len(body) and body[start] in b" \t\n\r\f\v":
|
||||
start += 1
|
||||
first = body[start : start + 1]
|
||||
if first in (b"{", b"<"):
|
||||
# Another JSON file of the export, or the history's HTML variant.
|
||||
raise TakeoutError("not_takeout")
|
||||
if first != b"[" and len(body) - start > CSV_MAX_BYTES:
|
||||
raise TakeoutError("too_many")
|
||||
try:
|
||||
# A bad UTF-8 sequence is a ValueError too (UnicodeDecodeError), read below as not a file.
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
# One import per user at a time: a second file uploaded while the first one's run is under way
|
||||
# starts a run of its own (the first one's id has left the scheduler), and the two would settle the
|
||||
# same rows. The second waits, then takes what the first left. One API process runs the scheduler,
|
||||
# so a lock in this module holds it (as the channel backfill's does).
|
||||
# One import per user at a time: a second file uploaded while the first one's run is under way runs
|
||||
# after it (the scheduler's ad-hoc queue), but the Shorts job settles the same users' rows on a
|
||||
# thread of its own, and the two would settle the same rows. One API process runs the scheduler, so a
|
||||
# lock in this module holds it (as the channel backfill's does).
|
||||
_user_locks: dict[int, threading.Lock] = {}
|
||||
_user_locks_guard = threading.Lock()
|
||||
|
||||
@@ -266,11 +338,14 @@ def _put_in_watch_later(db: Session, user: User) -> dict:
|
||||
if (
|
||||
fetcher is not None
|
||||
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 YouTubeClient(db, fetcher) as yt:
|
||||
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)
|
||||
# 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)
|
||||
@@ -301,13 +376,16 @@ 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
|
||||
`shorts` job, which is what clears the most of them. Returns its counts, or an empty dict when
|
||||
nothing was waiting that could move."""
|
||||
marked = added = 0
|
||||
marked = added = failed = 0
|
||||
ready = set(queries.takeout_users_ready(db))
|
||||
later = set(queries.takeout_users_watch_later_waiting(db))
|
||||
for user_id in sorted(ready | later):
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
continue
|
||||
# One user's failure (a lookup YouTube refused) is theirs: it neither fails the job that
|
||||
# runs this nor keeps the users after them waiting. What it did not settle waits.
|
||||
try:
|
||||
with _one_import(user_id):
|
||||
if user_id in ready:
|
||||
marked += service.apply_takeout_watched(db, user)["watched"]
|
||||
@@ -315,9 +393,19 @@ def apply_waiting(db: Session) -> dict:
|
||||
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 = {}
|
||||
if ready:
|
||||
out["takeout_watched"] = marked
|
||||
if added:
|
||||
out["takeout_watch_later_added"] = added
|
||||
if failed:
|
||||
out["takeout_failed"] = failed
|
||||
return out
|
||||
|
||||
+19
-10
@@ -309,38 +309,47 @@ def get_video_detail(
|
||||
# --- Google Takeout ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
# One upload at a time per user, and ten an hour: a file is held whole while it is read, so these
|
||||
# bound what one account can make the process hold. The section uploads its files one after another.
|
||||
# One upload at a time per user, ten an hour, and two at once across every user: a file's bytes are
|
||||
# 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_uploading: set[int] = set()
|
||||
_takeout_uploading_guard = threading.Lock()
|
||||
_TAKEOUT_AT_ONCE = 2
|
||||
|
||||
|
||||
async def _takeout_body(
|
||||
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> AsyncIterator[bytes]:
|
||||
# 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)
|
||||
with _takeout_uploading_guard:
|
||||
busy = user.id in _takeout_uploading
|
||||
if not busy:
|
||||
_takeout_uploading.add(user.id)
|
||||
if busy or not _takeout_limiter.allow(str(user.id)):
|
||||
if not busy:
|
||||
busy = uid in _takeout_uploading
|
||||
full = not busy and len(_takeout_uploading) >= _TAKEOUT_AT_ONCE
|
||||
taken = not busy and not full
|
||||
if taken:
|
||||
_takeout_uploading.add(uid)
|
||||
if not taken or not _takeout_limiter.allow(str(uid)):
|
||||
if taken:
|
||||
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
|
||||
# failure instead of the refusal (capped_stream_to says why).
|
||||
await drain_body(request)
|
||||
if busy:
|
||||
raise refused(429, "takeout_busy")
|
||||
if full:
|
||||
raise refused(429, "takeout_server_busy")
|
||||
raise refused(429, "takeout_cooldown")
|
||||
try:
|
||||
yield await capped_body(request, takeout.MAX_BYTES)
|
||||
finally:
|
||||
with _takeout_uploading_guard:
|
||||
_takeout_uploading.discard(user.id)
|
||||
_takeout_uploading.discard(uid)
|
||||
|
||||
|
||||
@router.post("/takeout")
|
||||
|
||||
+63
-10
@@ -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.
|
||||
_queued: set[str] = set()
|
||||
_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:
|
||||
@@ -650,37 +659,77 @@ def run_ad_hoc(
|
||||
) -> None:
|
||||
"""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
|
||||
a second request before the first has even started is that same run. `notify` posts its outcome
|
||||
to the user's inbox, for work whose page does not watch it run."""
|
||||
a second request before the first has even started is that same run, and one made while it runs
|
||||
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:
|
||||
raise KeyError(job) # an unnamed job would show on the page as its bare id
|
||||
sched = _scheduler
|
||||
if sched is None:
|
||||
if _scheduler is None:
|
||||
# Only between shutdown and exit: start_scheduler runs at every API startup.
|
||||
raise RuntimeError("the scheduler is not running")
|
||||
key = f"{job}@{target}"
|
||||
with _queued_guard:
|
||||
_pending[key] = (fn, actor_id, notify)
|
||||
_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:
|
||||
if sched is None:
|
||||
raise RuntimeError("the scheduler is not running")
|
||||
sched.add_job(
|
||||
_ad_hoc,
|
||||
_ad_hoc_fired,
|
||||
"date",
|
||||
args=(job, fn, actor_id, target, notify),
|
||||
args=(job, target),
|
||||
id=key,
|
||||
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:
|
||||
log.info("ad-hoc %s for %s already queued", job, target)
|
||||
except Exception:
|
||||
with _queued_guard:
|
||||
_pending.pop(key, None)
|
||||
_queued.discard(key)
|
||||
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(
|
||||
job: str, fn: Callable[[], dict], actor_id: int, target: str, notify: bool = False
|
||||
) -> None:
|
||||
key = f"{job}@{target}"
|
||||
try:
|
||||
run_id = jobruns.start(job, RunTrigger.AD_HOC, actor_id, target)
|
||||
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)
|
||||
return
|
||||
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:
|
||||
_queued.discard(f"{job}@{target}")
|
||||
if key not in _pending:
|
||||
_queued.discard(key)
|
||||
_opened(run_id, job)
|
||||
try:
|
||||
_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 = None
|
||||
with _queued_guard:
|
||||
_queued.clear() # what it held will not run
|
||||
# What it held will not run.
|
||||
_queued.clear()
|
||||
_pending.clear()
|
||||
|
||||
@@ -327,3 +327,15 @@ def _reset_log_erasures():
|
||||
from app import logs
|
||||
|
||||
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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
@@ -7,12 +7,15 @@ import tracemalloc
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import jobruns, quota, scheduler
|
||||
from app.db import SessionLocal
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.auth import current_user
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.catalog.models import Channel, Subscription, Video
|
||||
from app.models import JobRun, Notification, User
|
||||
from app.runstatus import RunStatus, RunTrigger
|
||||
from app.reader import queries, service, takeout
|
||||
@@ -145,6 +148,42 @@ def test_the_history_is_read_an_entry_at_a_time_not_as_one_tree():
|
||||
assert peak < 5 * len(body), f"parsing held {peak / len(body):.1f} times the file"
|
||||
|
||||
|
||||
def test_a_history_with_an_emoji_costs_less_than_its_own_size_to_read(monkeypatch):
|
||||
"""Decoded whole, one emoji makes the text four bytes a character: a 32 MiB history held some
|
||||
128 MiB of it on top of its bytes. Read a window at a time, it holds a window (a few times its
|
||||
bytes as text) whatever the file's size — a small one here, so a 9 MB file can show it."""
|
||||
monkeypatch.setattr(takeout, "_WINDOW", 64 * 1024)
|
||||
view = _view("aaaaaaaaaaa", title="Watched " + "😀" * 40)
|
||||
body = b"[" + b",".join([json.dumps(view, ensure_ascii=False).encode()] * 20_000) + b"]"
|
||||
tracemalloc.start()
|
||||
try:
|
||||
parsed = takeout.parse(body)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
assert parsed.entries == 20_000
|
||||
assert peak < len(body) / 2, f"parsing held {peak / len(body):.1f} times the file"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split", [1, 7, 100, 4096])
|
||||
def test_the_history_reads_the_same_whatever_its_window(monkeypatch, split):
|
||||
"""An entry, a character or a number cut by the window's edge is read whole."""
|
||||
monkeypatch.setattr(takeout, "_WINDOW", split)
|
||||
views = [_view(f"{c * 11}", title="ő😀" * 3) for c in "abcdef"]
|
||||
body = " [\n" + ",\n".join(json.dumps(v, ensure_ascii=False) for v in views) + "\n]\n"
|
||||
parsed = takeout.parse(body.encode())
|
||||
assert (parsed.entries, sorted(parsed.videos)) == (6, [c * 11 for c in "abcdef"])
|
||||
numbers = takeout._json_list(b"[123456789, 2]", 0)
|
||||
assert list(numbers) == [123456789, 2]
|
||||
|
||||
|
||||
def test_a_watch_later_file_far_larger_than_any_list_is_refused_unread():
|
||||
body = b"Video ID,Time\n" + b"x" * (takeout.CSV_MAX_BYTES)
|
||||
with pytest.raises(takeout.TakeoutError) as caught:
|
||||
takeout.parse(body)
|
||||
assert caught.value.reason == "too_many"
|
||||
|
||||
|
||||
def test_the_id_lists_are_asked_in_chunks(db, make_user, monkeypatch):
|
||||
user = make_user("chunks@example.com")
|
||||
ids = [f"chunk{n:06d}" for n in range(5)]
|
||||
@@ -253,6 +292,33 @@ def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_o
|
||||
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("undo", ["unmark", "clear_state"])
|
||||
def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user, undo):
|
||||
"""Un-marking (or resetting) a video leaves no state row, and the import inserted a watched one
|
||||
over the user's own choice. A view on YouTube after the un-mark is a new view, and counts."""
|
||||
user = make_user(f"{undo}@example.com")
|
||||
for vid in ("undone00000", "again000000"):
|
||||
_video(db, vid)
|
||||
db.commit()
|
||||
for vid in ("undone00000", "again000000"):
|
||||
service.mark(db, user, vid, "watched")
|
||||
getattr(service, undo)(db, user, vid)
|
||||
db.commit()
|
||||
assert _states(db, user) == {}
|
||||
later = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
service.stage_takeout(
|
||||
db,
|
||||
user,
|
||||
TakeoutKind.WATCHED,
|
||||
{"undone00000": (T0, None), "again000000": (later, None)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert takeout.run_import(user.id)["watched"] == 1
|
||||
assert set(_states(db, user)) == {"again000000"}
|
||||
assert _rows(db, user)["undone00000"].outcome == "kept"
|
||||
|
||||
|
||||
def test_a_second_upload_keeps_the_later_time_and_what_the_import_settled(db, make_user):
|
||||
user = make_user("again@example.com")
|
||||
_video(db, "plain000000")
|
||||
@@ -305,6 +371,40 @@ def test_the_watch_later_list_joins_the_users_own_oldest_first_after_what_it_hol
|
||||
assert db.execute(select(Playlist).where(Playlist.user_id == user.id)).scalars().all() == [pl]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n", [3, 40])
|
||||
def test_a_watch_later_list_goes_in_in_statements_that_do_not_grow_with_it(db, make_user, n):
|
||||
"""A video at a time, a full list of 5000 was some 20,000 statements under the user's lock."""
|
||||
from sqlalchemy import event
|
||||
|
||||
user = make_user(f"many{n}@example.com")
|
||||
ids = [f"wl{i:09d}" for i in range(n)]
|
||||
for vid in ids:
|
||||
_video(db, vid)
|
||||
db.commit()
|
||||
service.stage_takeout(
|
||||
db,
|
||||
user,
|
||||
TakeoutKind.WATCH_LATER,
|
||||
{v: (T0 + timedelta(minutes=i), None) for i, v in enumerate(ids)},
|
||||
)
|
||||
db.commit()
|
||||
rows = queries.takeout_waiting(db, user, TakeoutKind.WATCH_LATER)
|
||||
service.watch_later(db, user) # the list made beforehand: its own insert is not this count's
|
||||
db.flush()
|
||||
statements = []
|
||||
engine = db.get_bind()
|
||||
listener = lambda *a: statements.append(a[2]) # noqa: E731 — the event's own signature
|
||||
event.listen(engine, "before_cursor_execute", listener)
|
||||
try:
|
||||
assert service.apply_takeout_watch_later(db, user, rows, set())["watch_later_added"] == n
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", listener)
|
||||
db.commit()
|
||||
assert len(statements) <= 9, statements
|
||||
held = [i.video_id for i in queries.items(db, service.watch_later(db, user).id)]
|
||||
assert held == ids
|
||||
|
||||
|
||||
def test_the_job_fetches_the_unknown_watch_later_videos_and_says_what_waits(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
@@ -407,6 +507,38 @@ def test_the_shorts_job_puts_in_the_watch_later_videos_a_run_could_not(db, make_
|
||||
assert takeout.apply_waiting(db) == {}
|
||||
|
||||
|
||||
def test_one_users_failure_in_the_shorts_job_leaves_the_others_and_the_probe_alone(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
"""A Watch later lookup that raised (a revoked token, a refused quota) escaped the Shorts job:
|
||||
its run was recorded as failed with its own counts lost, and every user after this one waited."""
|
||||
first, second = make_user("first@example.com"), make_user("second@example.com")
|
||||
_video(db, "ready000000")
|
||||
db.commit()
|
||||
service.stage_takeout(db, first, TakeoutKind.WATCH_LATER, {"unknown0000": (T0, None)})
|
||||
service.stage_takeout(db, second, TakeoutKind.WATCHED, {"ready000000": (T0, None)})
|
||||
db.commit()
|
||||
|
||||
def refused(session, yt, ids):
|
||||
raise RuntimeError("invalid_grant")
|
||||
|
||||
monkeypatch.setattr(takeout, "ensure_videos", refused)
|
||||
monkeypatch.setattr(takeout, "get_service_user", lambda session: first)
|
||||
monkeypatch.setattr(scheduler, "run_shorts", lambda session: {"probed": 4})
|
||||
|
||||
scheduler._shorts_job()
|
||||
|
||||
db.expire_all()
|
||||
run = db.execute(select(JobRun).where(JobRun.job == "shorts")).scalar_one()
|
||||
assert run.status == "ok"
|
||||
assert run.result == {"probed": 4, "takeout_watched": 1, "takeout_failed": 1}
|
||||
assert _states(db, second)["ready000000"].status == "watched"
|
||||
assert _rows(db, first)["WL:unknown0000"].outcome is None
|
||||
# A lookup that failed spent none of the user's daily share.
|
||||
assert not takeout._lookups.exhausted(str(first.id))
|
||||
assert takeout._lookups.room(str(first.id)) == 2 * takeout.WATCH_LATER_MAX
|
||||
|
||||
|
||||
def test_a_watch_later_row_staged_while_youtube_is_asked_is_left_for_the_next_run(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
@@ -469,6 +601,9 @@ def test_staging_and_settling_take_the_users_one_lock(db, make_user):
|
||||
"watch later": lambda s, u: service.apply_takeout_watch_later(
|
||||
s, u, [(1, "aaaaaaaaaaa")], set()
|
||||
),
|
||||
# Between the import's read of the un-marks and its marking, one would be overwritten.
|
||||
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
|
||||
"reset": lambda s, u: service.clear_state(s, u, "aaaaaaaaaaa"),
|
||||
}
|
||||
with SessionLocal() as holder:
|
||||
service.lock_takeout(holder, holder.get(User, user.id))
|
||||
@@ -570,6 +705,39 @@ def test_a_signed_in_upload_has_its_body_read(db, make_user, as_user, queued):
|
||||
assert len(pulled) == 4
|
||||
|
||||
|
||||
def test_the_upload_arrives_with_no_connection_held(
|
||||
db, make_user, as_user, override_dep, queued, monkeypatch
|
||||
):
|
||||
"""The read connection is given back before the body: reading the user's id after that reloaded
|
||||
the user and took one straight back out, `idle in transaction` for the whole upload."""
|
||||
user = make_user("idle@example.com")
|
||||
client = as_user(user)
|
||||
|
||||
def signed_in(session: Session = Depends(get_db)) -> User:
|
||||
# As the real sign-in check: a SELECT on the request's session, which the release ends —
|
||||
# and expires what it loaded. (`as_user`'s own resolver reads nothing.)
|
||||
session.expire_all()
|
||||
return session.get(User, user.id)
|
||||
|
||||
override_dep(current_user, signed_in)
|
||||
sessions, held = [], []
|
||||
release, capped = feed_routes.release_read_connection, feed_routes.capped_body
|
||||
|
||||
def releasing(session):
|
||||
sessions.append(session)
|
||||
release(session)
|
||||
|
||||
async def reading(request, limit):
|
||||
held.extend(s.in_transaction() for s in sessions)
|
||||
return await capped(request, limit)
|
||||
|
||||
monkeypatch.setattr(feed_routes, "release_read_connection", releasing)
|
||||
monkeypatch.setattr(feed_routes, "capped_body", reading)
|
||||
resp = client.post("/api/takeout", content=_history(_view("aaaaaaaaaaa")))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert held == [False]
|
||||
|
||||
|
||||
def test_uploads_past_the_hourly_limit_are_refused_after_their_body_arrived(
|
||||
db, make_user, as_user, queued
|
||||
):
|
||||
@@ -600,6 +768,20 @@ def test_a_second_upload_while_one_of_the_users_is_still_arriving_is_refused(
|
||||
assert feed_routes._takeout_uploading == {user.id}
|
||||
|
||||
|
||||
def test_a_third_upload_across_users_while_two_arrive_is_refused_after_its_body(
|
||||
db, make_user, as_user, queued, monkeypatch
|
||||
):
|
||||
"""Each upload's bytes are held whole while it arrives: with no bound across users, a few at
|
||||
once could take the process past its memory."""
|
||||
monkeypatch.setattr(feed_routes, "_takeout_uploading", {9001, 9002})
|
||||
body, pulled = _counting_body()
|
||||
resp = as_user(make_user("third@example.com")).post("/api/takeout", content=body)
|
||||
assert resp.status_code == 429
|
||||
assert resp.json()["detail"]["reason"] == "takeout_server_busy"
|
||||
assert len(pulled) == 4
|
||||
assert queued == [] and feed_routes._takeout_uploading == {9001, 9002}
|
||||
|
||||
|
||||
def test_an_upload_that_would_keep_too_many_videos_is_refused(
|
||||
db, make_user, as_user, queued, monkeypatch
|
||||
):
|
||||
@@ -679,12 +861,49 @@ def test_a_queued_import_is_told_apart_from_the_last_one_until_its_run_is_record
|
||||
assert scheduler.ad_hoc_queued("takeout_import", target)
|
||||
assert as_user(user).get("/api/takeout").json()["queued"] is True
|
||||
|
||||
scheduler._ad_hoc("takeout_import", dict, user.id, target)
|
||||
scheduler._ad_hoc_fired("takeout_import", target)
|
||||
assert not scheduler.ad_hoc_queued("takeout_import", target)
|
||||
assert as_user(user).get("/api/takeout").json()["queued"] is False
|
||||
assert added == [f"takeout_import@{target}"]
|
||||
|
||||
|
||||
def test_an_upload_while_its_import_starts_stays_queued_for_the_run_after(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
"""A request landing while the run before it writes its row is the run after that one: still
|
||||
queued, so the page does not take the run starting now for it."""
|
||||
user = make_user("starting@example.com")
|
||||
added, ran = [], []
|
||||
|
||||
class Held:
|
||||
def add_job(self, *args, **kwargs):
|
||||
added.append(kwargs["id"])
|
||||
|
||||
monkeypatch.setattr(scheduler, "_scheduler", Held())
|
||||
target = takeout.run_target(user.id)
|
||||
start = jobruns.start
|
||||
|
||||
def starting(*args, **kwargs):
|
||||
scheduler.run_ad_hoc(
|
||||
"takeout_import", lambda: ran.append(2) or {}, actor_id=user.id, target=target
|
||||
)
|
||||
return start(*args, **kwargs)
|
||||
|
||||
scheduler.run_ad_hoc(
|
||||
"takeout_import", lambda: ran.append(1) or {}, actor_id=user.id, target=target
|
||||
)
|
||||
monkeypatch.setattr(scheduler.jobruns, "start", starting)
|
||||
scheduler._ad_hoc_fired("takeout_import", target)
|
||||
assert ran == [1]
|
||||
assert scheduler.ad_hoc_queued("takeout_import", target)
|
||||
assert added == [f"takeout_import@{target}"] * 2 # the run after it, asked for as it ended
|
||||
|
||||
monkeypatch.setattr(scheduler.jobruns, "start", start)
|
||||
scheduler._ad_hoc_fired("takeout_import", target)
|
||||
assert ran == [1, 2]
|
||||
assert not scheduler.ad_hoc_queued("takeout_import", target)
|
||||
|
||||
|
||||
# --- the videos that wait ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -711,6 +930,39 @@ def test_waiting_videos_are_marked_once_the_shorts_probe_has_cleared_them(db, ma
|
||||
assert takeout.apply_waiting(db) == {}
|
||||
|
||||
|
||||
def test_a_discovery_video_is_not_marked_until_the_catalog_keeps_it(db, make_user):
|
||||
"""A mark is a VideoState, which the cleanup jobs read as a reader keeping the video: on a video
|
||||
only someone's search or explore brought in, it kept that video (and its channel) for good."""
|
||||
user = make_user("discover@example.com")
|
||||
for vid, flag in (("search00000", "via_search"), ("explore0000", "via_explore")):
|
||||
_video(db, vid)
|
||||
db.flush()
|
||||
setattr(db.get(Video, vid), flag, True)
|
||||
_video(db, "plain000000")
|
||||
db.commit()
|
||||
service.stage_takeout(
|
||||
db,
|
||||
user,
|
||||
TakeoutKind.WATCHED,
|
||||
{v: (T0, None) for v in ("search00000", "explore0000", "plain000000")},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert takeout.apply_waiting(db) == {"takeout_watched": 1}
|
||||
assert set(_states(db, user)) == {"plain000000"}
|
||||
assert {v for v, r in _rows(db, user).items() if r.outcome is None} == {
|
||||
"search00000",
|
||||
"explore0000",
|
||||
}
|
||||
|
||||
# Subscribed to: the catalog keeps the channel's videos of its own, the search one included.
|
||||
db.add(Subscription(user_id=user.id, channel_id=CHANNEL))
|
||||
db.get(Video, "explore0000").via_explore = False # as subscribing does (catalog.service)
|
||||
db.commit()
|
||||
assert takeout.apply_waiting(db) == {"takeout_watched": 2}
|
||||
assert set(_states(db, user)) == {"plain000000", "search00000", "explore0000"}
|
||||
|
||||
|
||||
def test_the_shorts_job_settles_the_waiting_rows_its_probe_made_ready(db, make_user):
|
||||
user = make_user("job-shorts@example.com")
|
||||
_video(db, "ready000000") # probed already: the job's probe has nothing left to do
|
||||
|
||||
@@ -45,6 +45,7 @@ UNIT_MODELS = {
|
||||
"SavedView",
|
||||
"ComfortPin",
|
||||
"TakeoutItem",
|
||||
"VideoUnmark",
|
||||
"Tag",
|
||||
"ChannelTag",
|
||||
},
|
||||
|
||||
@@ -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_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_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_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"
|
||||
|
||||
@@ -188,7 +188,8 @@
|
||||
"watch_later_missing": "no longer on YouTube",
|
||||
"watch_later_waiting": "Watch later waiting",
|
||||
"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": {
|
||||
"disabled": "disabled",
|
||||
|
||||
@@ -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_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_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_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"
|
||||
|
||||
@@ -188,7 +188,8 @@
|
||||
"watch_later_missing": "már nincs a YouTube-on",
|
||||
"watch_later_waiting": "a „Megnézem később” listára vár",
|
||||
"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": {
|
||||
"disabled": "kikapcsolva",
|
||||
|
||||
Reference in New Issue
Block a user