fix(plex): make watch import concurrency-safe (upsert), guard toggle

The Plex watch import inserted plex_states via per-row ORM add against a pre-read 'existing' set,
which is not safe when the enable call fires more than once while the multi-second import runs (the
toggle wasn't disabled during it): overlapping imports raced to INSERT the same (user_id, item_id)
rows and all but the first hit a UniqueViolation -> 500 (seen on prod). Rewrite the write path as a
chunked PostgreSQL UPSERT (on_conflict_do_update, rows de-duped by item_id) — idempotent and
concurrency-safe, Plex still wins on the intersection and Siftlode-only states are untouched.

Also disable the Settings toggle (and ignore its onChange) while a sync mutation is pending, so it
can't be fired repeatedly; adds a reusable 'disabled' prop to the Switch primitive.
This commit is contained in:
npeter83 2026-07-09 15:18:19 +02:00
parent b09963be2b
commit 5592f60e85
3 changed files with 43 additions and 18 deletions

View file

@ -16,6 +16,8 @@ history + onDeck, last-write-wins) build on this and land in later ships.
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
@ -67,13 +69,12 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
item_id_by_rk: dict[str, int] = { item_id_by_rk: dict[str, int] = {
rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id) rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)
} }
# This user's existing states, by item — so we upsert rather than duplicate.
existing: dict[int, PlexState] = {
st.item_id: st
for st in db.query(PlexState).filter_by(user_id=link.user_id)
}
stats = {"watched": 0, "in_progress": 0, "scanned": 0} stats = {"watched": 0, "in_progress": 0, "scanned": 0}
wanted = _enabled_section_keys(db) wanted = _enabled_section_keys(db)
# Accumulate one row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert
# is idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice while
# the multi-second import runs) can't raise a duplicate-key error — the conflict just re-updates.
rows_by_item: dict[int, dict] = {}
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
@ -98,18 +99,35 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
if res is None: if res is None:
continue continue
status, pos, watched_at, prog_at = res status, pos, watched_at, prog_at = res
st = existing.get(item_id) rows_by_item[item_id] = {
if st is None: "user_id": link.user_id,
st = PlexState(user_id=link.user_id, item_id=item_id) "item_id": item_id,
db.add(st) "status": status,
existing[item_id] = st "position_seconds": pos,
st.status = status "watched_at": watched_at,
st.position_seconds = pos "progress_updated_at": prog_at,
st.watched_at = watched_at # Mirrors Plex now — so Phase B's push doesn't bounce it straight back.
st.progress_updated_at = prog_at "synced_to_plex": True,
# This state now mirrors Plex — mark it so Phase B's push doesn't bounce it back. }
st.synced_to_plex = True
stats["watched" if status == "watched" else "in_progress"] += 1 stats["watched" if status == "watched" else "in_progress"] += 1
# Bulk upsert in chunks. Plex wins on the intersection; Siftlode-only states (item_ids not
# in rows_by_item) are never referenced, so they're preserved untouched (union).
rows = list(rows_by_item.values())
for i in range(0, len(rows), 1000):
stmt = pg_insert(PlexState).values(rows[i : i + 1000])
stmt = stmt.on_conflict_do_update(
index_elements=[PlexState.user_id, PlexState.item_id],
set_={
"status": stmt.excluded.status,
"position_seconds": stmt.excluded.position_seconds,
"watched_at": stmt.excluded.watched_at,
"progress_updated_at": stmt.excluded.progress_updated_at,
"synced_to_plex": stmt.excluded.synced_to_plex,
"updated_at": func.now(),
},
)
db.execute(stmt)
link.initial_import_done = True link.initial_import_done = True
link.last_watch_sync_at = datetime.now(timezone.utc) link.last_watch_sync_at = datetime.now(timezone.utc)
db.commit() db.commit()

View file

@ -469,7 +469,11 @@ function PlexWatchSync() {
<Section title={t("settings.plexSync.title")}> <Section title={t("settings.plexSync.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p> <p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}> <SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
<Switch checked={enabled} onChange={(v) => toggle.mutate(v)} /> <Switch
checked={enabled}
disabled={toggle.isPending || reimport.isPending}
onChange={(v) => !toggle.isPending && toggle.mutate(v)}
/>
</SettingRow> </SettingRow>
{enabled && ( {enabled && (
<div className="mt-2 flex items-center justify-between gap-3"> <div className="mt-2 flex items-center justify-between gap-3">

View file

@ -10,10 +10,12 @@ export function Switch({
checked, checked,
onChange, onChange,
label, label,
disabled,
}: { }: {
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
label?: string; label?: string;
disabled?: boolean;
}) { }) {
return ( return (
<button <button
@ -21,8 +23,9 @@ export function Switch({
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-label={label} aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`} className={`w-9 h-5 rounded-full transition relative shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${checked ? "bg-accent" : "bg-border"}`}
> >
<span <span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${ className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${