Files
FabledCurator/backend/app/services/platform_lock.py
T
bvandeusenandClaude Opus 5.5 da98195ada
CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 23s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 53s
CI and images / integration (push) Successful in 2m50s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
refactor: the native downloaders share one download loop, file record and seen-ledger key; a clean walk marks its credential verified
Patreon, SubscribeStar and Discord each carried their own copy of the same
per-post download loop, file record and seen-ledger key, and every copy was
another place a fix could be missed.

- BaseNativeDownloader runs one post's files: the seen and on-disk skips,
  the mid-post time-box, pacing, validation and the per-file sidecar. Each
  platform overrides only where its layout differs: Discord's channel folder,
  file names and message-id sidecar, and Patreon's Mux video through yt-dlp.
- One MediaItem and one ledger_key in native_ingest_common replace the three
  per-client copies. Keys are unchanged, except that a SubscribeStar file with
  no gallery id is now keyed by its filename instead of an empty id.
- DownloadResult, ErrorType and the run-stats and tier-gated helpers move
  from gallery_dl.py, which now serves only HentaiFoundry, to
  download_result.py. The unused delegator methods on GalleryDLService go.
- The download lock's platform list is the native-ingester list, not a copy.
- A walk that used a credential and finished with no error type stamps that
  credential's last-verified time, so the credentials page shows when the
  account last worked. A tier-limited walk does not count: an expired session
  hides paid posts the same way.
- The extension no longer clears a retired pixiv token on startup.
- Two comments that still described every download as gallery-dl now name
  both backends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:03:32 -05:00

82 lines
3.3 KiB
Python

"""Per-platform download concurrency cap.
Some platforms (Patreon) are API-rate-sensitive enough that two *simultaneous*
walks can trip the server's rate limit even with each source pacing its own
requests. The platform-cooldown handles the AFTERMATH of a 429; this is the
preventive half — it serializes downloads PER PLATFORM to one at a time.
Different platforms still run concurrently up to the worker's concurrency; only
a second walk on the SAME serialized platform waits. The lock lives in Redis
(the Celery broker) with a TTL, so a SIGKILL'd worker can't wedge a platform —
the lock auto-expires shortly after the download hard time limit.
"""
from __future__ import annotations
import logging
import redis
from ..config import get_config
from .download_backends import NATIVE_INGESTER_PLATFORMS
log = logging.getLogger(__name__)
# Platforms walked one-at-a-time: every native-ingester platform, one paced
# scrape/API walk at a time. gallery-dl platforms are not: each runs as a
# self-pacing subprocess and they are lower-volume. Discord most of all needs
# it: every source walks on the operator's ONE user token, and parallel walks
# on a user account are both how its rate limit trips and what gets it flagged.
SERIALIZED_PLATFORMS = NATIVE_INGESTER_PLATFORMS
_LOCK_PREFIX = "fc:download_lock:"
_client: redis.Redis | None = None
def _redis() -> redis.Redis:
# One client per worker process (Celery prefork forks before tasks run, so
# each process lazily builds its own). redis-py pools connections.
global _client
if _client is None:
_client = redis.from_url(get_config().celery_broker_url)
return _client
def platform_lock(platform: str, *, ttl_seconds: int):
"""A non-blocking Redis lock for `platform`, or None when the platform is
not serialized. Caller does `.acquire(blocking=False)` / `.release()`.
Returns None (rather than raising) on any Redis error so a broker hiccup
degrades to the prior behaviour (uncapped) instead of stalling downloads.
"""
if platform not in SERIALIZED_PLATFORMS:
return None
try:
return _redis().lock(
f"{_LOCK_PREFIX}{platform}",
timeout=ttl_seconds,
blocking=False,
)
except redis.RedisError as exc: # pragma: no cover - broker outage
log.warning("platform_lock unavailable for %s: %s", platform, exc)
return None
def release_all_platform_locks() -> int:
"""Drop every serialized platform's lock. Returns how many were held.
Only for the download lane's boot (#4433). A worker that restarts mid-walk
is SIGKILLed past its stop grace, so its `finally` never releases the lock,
and the TTL keeps every other source on that platform bouncing for up to
27 minutes after the new worker is ready. At boot no walk of ours can be
running, so a held lock names a dead one. Assumes one download consumer —
the only shape FC deploys; a second replica booting would free a live
walk's lock (not corrupt it: the walk runs on, a second walk may overlap it).
"""
try:
client = _redis()
return int(client.delete(*(f"{_LOCK_PREFIX}{p}" for p in SERIALIZED_PLATFORMS)))
except redis.RedisError as exc: # pragma: no cover - broker outage
log.warning("could not release platform locks at boot: %s", exc)
return 0