Files
mule-image/backend/scripts/backfill_nextcloud_fileid.py
Claudio 28738acb56 fix(backfill): drop offset-based pagination — it skipped filled rows
The offset+limit loop walked the IS NULL set, but every batch's writes
shrank that set, so batch N+1 with offset=N*BATCH skipped over the rows
just filled. A 17k library backfilled only 9k before the loop walked
off the (now-shorter) NULL set.

Replace with a tail-recursive pattern: keep selecting LIMIT BATCH on
the NULL set, tracking rows that won't ever resolve in a `stuck` set so
the loop terminates instead of spinning on them.
2026-05-11 11:43:03 +02:00

141 lines
4.9 KiB
Python

"""Backfill Photo.nextcloud_fileid for photos under Nextcloud-rooted paths.
The Phase-1 thumbnail proxy reads `Photo.nextcloud_fileid` to know which
file to ask Nextcloud's /core/preview endpoint about. New photos pick it
up at scan time; this script catches up the existing library.
Run inside the backend container, e.g.:
pct exec 120 -- docker exec mulita-backend python -m scripts.backfill_nextcloud_fileid
Idempotent: skips rows that already have nextcloud_fileid set, and any
row whose path isn't under the Nextcloud bind mount. One PROPFIND per
photo. At ~50ms each that's ~18 minutes for a 22k-row library — run
during off-hours.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import Photo
from app.models.user import User
from app.services.nextcloud_dav import fetch_fileid, is_nextcloud_path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("backfill_nextcloud_fileid")
BATCH = 500
async def _user_cache(session: AsyncSession) -> dict[str, User]:
"""One SELECT per script run instead of per photo."""
result = await session.execute(select(User))
return {u.id: u for u in result.scalars().all()}
async def run() -> None:
async with AsyncSessionLocal() as session:
users = await _user_cache(session)
total = await session.scalar(
select(func.count(Photo.id)).where(Photo.nextcloud_fileid.is_(None))
)
logger.info("photos with NULL nextcloud_fileid: %s", total)
done = 0
skipped_no_user = 0
skipped_not_nc = 0
skipped_no_creds = 0
filled = 0
not_found = 0
# Keep selecting the next batch of NULL-fileid rows until the
# set is empty. NO offset() — each batch's writes shrink the
# `WHERE nextcloud_fileid IS NULL` set, so an offset would skip
# over the rows that were just filled in by the previous batch.
# Rows we couldn't resolve (skipped or not_found) stay in the
# set; we track them in a "stuck ids" set so the loop terminates
# instead of spinning on them forever.
stuck: set[str] = set()
while True:
stmt = (
select(Photo)
.where(Photo.nextcloud_fileid.is_(None))
.order_by(Photo.id)
.limit(BATCH)
)
if stuck:
stmt = stmt.where(Photo.id.notin_(stuck))
result = await session.execute(stmt)
rows = list(result.scalars().all())
if not rows:
break
batch_started_filled = filled
for photo in rows:
done += 1
if not photo.user_id:
skipped_no_user += 1
stuck.add(photo.id)
continue
owner = users.get(photo.user_id)
if owner is None:
skipped_no_user += 1
stuck.add(photo.id)
continue
if not photo.filepath or not is_nextcloud_path(photo.filepath):
skipped_not_nc += 1
stuck.add(photo.id)
continue
if not owner.nextcloud_app_password_enc:
skipped_no_creds += 1
stuck.add(photo.id)
continue
fid: Optional[int] = None
try:
fid = fetch_fileid(owner, photo.filepath)
except Exception as e:
logger.warning(
"PROPFIND failed for photo %s (%s): %s",
photo.id, photo.filepath, e,
)
if fid is None:
not_found += 1
stuck.add(photo.id)
continue
photo.nextcloud_fileid = fid
filled += 1
await session.commit()
# Safety: if a whole batch produced no new fills, every row
# in it is already in `stuck` — break to avoid an infinite
# loop on the same set.
if filled == batch_started_filled and len(rows) < BATCH:
break
logger.info(
"progress: scanned=%s filled=%s not_found=%s "
"skipped(no_user=%s not_nc=%s no_creds=%s) of total=%s",
done, filled, not_found,
skipped_no_user, skipped_not_nc, skipped_no_creds,
total,
)
logger.info(
"done: scanned=%s filled=%s not_found=%s "
"skipped(no_user=%s not_nc=%s no_creds=%s)",
done, filled, not_found,
skipped_no_user, skipped_not_nc, skipped_no_creds,
)
if __name__ == "__main__":
asyncio.run(run())