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:
parent
b09963be2b
commit
5592f60e85
3 changed files with 43 additions and 18 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 ${
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue