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
+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.
_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()