Skip to content

Commit c207eb1

Browse files
committed
fix(subscriptions): collapse fingerprint duplicates in upsert
Panels (notably Happ JSON bundles) return the same logical server multiple times — one outbound per SNI/fingerprint variant for domain-fronting resilience. Observed on s.stun.su returning 1256 outbounds for happ-macos UA across 303 unique (addr,port,uuid) servers. The pre-1.3.6 delete-and-insert flow created 1256 Node rows; the 1.3.6 upsert kept them around forever as orphans (their fp is in seen_fps so not deleted, but only one row per fp is the matched 'existing' so others are never updated either). Two fixes: 1. Dedup parsed list by fingerprint before the upsert loop — multiple SNI variants of the same (protocol, addr, port, uuid, password) collapse to one Node row, last-wins on mutable fields. 2. Collapse legacy duplicates from the DB on every refresh. Pick the smallest-id row as the survivor (stable choice — minimizes external reference breakage) and transparently remap any active_node_id / NodeCircle.node_ids reference from a dup to the survivor before deleting the dup. User never notices the cleanup. Tests: 3 new in TestSubscriptionRefreshDedupsParsed covering parsed dedup, legacy DB dedup, and cross-reference remap (active + circle both pointing at dups that get collapsed).
1 parent bf17641 commit c207eb1

2 files changed

Lines changed: 289 additions & 2 deletions

File tree

backend/app/api/subscriptions.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,14 +426,98 @@ async def _fetch_subscription_unlocked(sub_id: int) -> None:
426426
# remap is possible, pick the first remaining enabled +
427427
# online node from this subscription as a fallback so the
428428
# user doesn't lose proxy after a refresh.
429+
430+
# First: dedup `parsed` by fingerprint. Panels (especially Happ
431+
# JSON bundles) often expose the SAME (addr, port, uuid) server
432+
# under multiple SNI / fingerprint / sid combos for domain-
433+
# fronting resilience — each is one outbound entry. Our Node
434+
# model treats (protocol, addr, port, uuid, password) as the
435+
# unit (see `_node_fingerprint`), so collapse variants to one
436+
# row, last-wins. Without this, the upsert touches only one
437+
# row per fingerprint and the other duplicates from the OLD
438+
# delete-and-insert era stay forever as orphans (their fp is
439+
# in `seen_fps` → not removed; never the matched `existing` →
440+
# not updated). Seen in the wild on a Happ-macos subscription
441+
# that returned 1256 outbounds for 303 unique servers; without
442+
# this dedup the row count never collapsed back to 303.
443+
deduped: dict[str, dict] = {}
444+
for n in parsed:
445+
fp = _node_fingerprint(n)
446+
deduped[fp] = n # last-wins
447+
parsed_dedup_skipped = len(parsed) - len(deduped)
448+
parsed = list(deduped.values())
449+
if parsed_dedup_skipped > 0:
450+
logger.info(
451+
"Subscription %d: collapsed %d duplicate parsed entries "
452+
"(same fingerprint, different SNI/fp variants)",
453+
sub_id, parsed_dedup_skipped,
454+
)
455+
429456
old_nodes = (await session.exec(
430457
select(Node).where(Node.subscription_id == sub_id)
431458
)).all()
459+
# `old_by_fp` keys by fingerprint; multiple old rows with the
460+
# same fingerprint (legacy duplicates from pre-1.3.6 inserts)
461+
# collapse here — we keep the survivor with the smallest id
462+
# (so external references — active_node_id, NodeCircle,
463+
# RoutingRule — that point at the lowest id of a fingerprint
464+
# group keep working) and remap all references on the other
465+
# rows to the survivor before deleting them.
432466
old_by_fp: dict = {}
433467
old_by_id: dict = {}
434-
for n in old_nodes:
435-
old_by_fp[_node_row_fingerprint(n)] = n
468+
# Process in id-ascending order so the FIRST seen for each fp
469+
# is the smallest id → "survivor" is stable.
470+
for n in sorted(old_nodes, key=lambda r: r.id):
471+
fp = _node_row_fingerprint(n)
472+
if fp not in old_by_fp:
473+
old_by_fp[fp] = n
436474
old_by_id[n.id] = n
475+
# Build {legacy_dup_id → survivor_id} map for transparent remap.
476+
legacy_dup_remap: dict[int, int] = {}
477+
for n in old_nodes:
478+
fp = _node_row_fingerprint(n)
479+
survivor = old_by_fp[fp]
480+
if survivor.id != n.id:
481+
legacy_dup_remap[n.id] = survivor.id
482+
483+
if legacy_dup_remap:
484+
# Rewrite NodeCircle.node_ids so legacy-dup ids are
485+
# transparently swapped for their fingerprint survivor.
486+
# Also dedup within the list (a circle that referenced
487+
# both halves of a dup pair shouldn't end up with the
488+
# same survivor id twice).
489+
import json as _json
490+
all_circles_pre = (await session.exec(select(NodeCircle))).all()
491+
for circle in all_circles_pre:
492+
try:
493+
ids = (
494+
_json.loads(circle.node_ids)
495+
if isinstance(circle.node_ids, str)
496+
else (circle.node_ids or [])
497+
)
498+
except Exception:
499+
continue
500+
remapped: list[int] = []
501+
seen: set[int] = set()
502+
for i in ids:
503+
new_i = legacy_dup_remap.get(i, i)
504+
if new_i not in seen:
505+
remapped.append(new_i)
506+
seen.add(new_i)
507+
if remapped != ids:
508+
if circle.current_index >= len(remapped):
509+
circle.current_index = 0
510+
circle.node_ids = _json.dumps(remapped)
511+
session.add(circle)
512+
513+
# Delete the legacy dup rows now.
514+
for dup_id in legacy_dup_remap:
515+
await session.delete(old_by_id[dup_id])
516+
logger.info(
517+
"Subscription %d: removed %d legacy duplicate Node rows "
518+
"(same fingerprint as another row, refs remapped to survivor)",
519+
sub_id, len(legacy_dup_remap),
520+
)
437521

438522
# Snapshot active node id (may live in this subscription or in
439523
# another one — we only care if it's in THIS subscription's
@@ -450,6 +534,25 @@ async def _fetch_subscription_unlocked(sub_id: int) -> None:
450534
active_was_in_sub = (
451535
active_id_before is not None and active_id_before in old_by_id
452536
)
537+
# If the active node was one of the legacy duplicates we just
538+
# deleted, transparently remap to the surviving sibling with
539+
# the same fingerprint and persist immediately. This keeps the
540+
# user's "active" pin on the same logical server through the
541+
# dedup pass, with no UI gap.
542+
if (
543+
active_id_before is not None
544+
and active_id_before in legacy_dup_remap
545+
):
546+
survivor_id = legacy_dup_remap[active_id_before]
547+
logger.warning(
548+
"Subscription %d: active_node_id %d was a legacy duplicate "
549+
"of %d — remapping to survivor",
550+
sub_id, active_id_before, survivor_id,
551+
)
552+
if active_row is not None:
553+
active_row.value = str(survivor_id)
554+
session.add(active_row)
555+
active_id_before = survivor_id # downstream heal sees survivor
453556

454557
# Field copy list — keep in sync with Node ORM. We deliberately
455558
# don't blow away `order` / `last_check` / `latency_ms` /

backend/tests/test_subscriptions.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,190 @@ def test_empty_subscription_response_does_not_touch_circle(
422422
assert circle_after.enabled is True
423423

424424

425+
class TestSubscriptionRefreshDedupsParsed:
426+
"""Panels (especially Happ JSON bundles) often return the SAME
427+
(addr,port,uuid) server under multiple SNI/fingerprint variants.
428+
Our Node model treats those as a single row (see
429+
`_node_fingerprint`). The upsert must collapse parsed entries to
430+
one-per-fingerprint, otherwise legacy duplicate rows accumulate
431+
and never get cleaned up. Also: refs from active_node + circles
432+
must remap from any deleted dup to the surviving sibling."""
433+
434+
def test_parsed_duplicates_collapse_to_unique_count(
435+
self, client, admin_user, auth_headers, session,
436+
):
437+
import asyncio, json
438+
from app.api.subscriptions import _fetch_subscription_unlocked
439+
from app.models import Subscription, Node
440+
441+
sub = Subscription(name="Test", url="http://example/sub", enabled=True)
442+
session.add(sub)
443+
session.commit()
444+
session.refresh(sub)
445+
446+
# Panel returns 5 lines for the SAME (addr,port,uuid), differing
447+
# only in SNI — these should collapse to 1 Node row.
448+
body = "\n".join(
449+
f"vless://uuid-A@server.example:443?type=tcp&sni=sni{i}.example#name{i}"
450+
for i in range(5)
451+
)
452+
453+
with mock.patch(
454+
"app.api.subscriptions.httpx.AsyncClient"
455+
) as mock_client:
456+
instance = mock_client.return_value.__aenter__.return_value
457+
instance.get = AsyncMock(return_value=mock.Mock(
458+
status_code=200, text=body,
459+
raise_for_status=lambda: None,
460+
))
461+
asyncio.run(_fetch_subscription_unlocked(sub.id))
462+
463+
session.expire_all()
464+
nodes = session.query(Node).filter(
465+
Node.subscription_id == sub.id
466+
).all()
467+
assert len(nodes) == 1, (
468+
f"5 SNI variants of one server should collapse to 1 Node, "
469+
f"got {len(nodes)}"
470+
)
471+
472+
def test_legacy_duplicates_in_db_collapse_on_refresh(
473+
self, client, admin_user, auth_headers, session,
474+
):
475+
"""Inverse case: DB already carries 3 legacy duplicate rows
476+
(same fingerprint) from pre-1.3.6 inserts; refresh must keep
477+
the smallest-id row and delete the other two."""
478+
import asyncio, json
479+
from app.api.subscriptions import _fetch_subscription_unlocked
480+
from app.models import Subscription, Node
481+
482+
sub = Subscription(name="Test", url="http://example/sub", enabled=True)
483+
session.add(sub)
484+
session.commit()
485+
session.refresh(sub)
486+
487+
# 3 rows, all with same (protocol, addr, port, uuid) — only
488+
# SNI varies. They're legacy dups that need collapsing.
489+
rows = [
490+
Node(name=f"dup{i}", protocol="vless", address="server.example",
491+
port=443, uuid="uuid-A", transport="tcp", sni=f"sni{i}",
492+
subscription_id=sub.id, enabled=True)
493+
for i in range(3)
494+
]
495+
for r in rows:
496+
session.add(r)
497+
session.commit()
498+
for r in rows:
499+
session.refresh(r)
500+
ids_before = sorted(r.id for r in rows)
501+
survivor_id = ids_before[0]
502+
503+
with mock.patch(
504+
"app.api.subscriptions.httpx.AsyncClient"
505+
) as mock_client:
506+
instance = mock_client.return_value.__aenter__.return_value
507+
instance.get = AsyncMock(return_value=mock.Mock(
508+
status_code=200,
509+
text="vless://uuid-A@server.example:443?type=tcp&sni=fresh#name",
510+
raise_for_status=lambda: None,
511+
))
512+
asyncio.run(_fetch_subscription_unlocked(sub.id))
513+
514+
session.expire_all()
515+
nodes = session.query(Node).filter(
516+
Node.subscription_id == sub.id
517+
).all()
518+
assert len(nodes) == 1, (
519+
f"3 legacy dups should collapse to 1, got {len(nodes)}"
520+
)
521+
assert nodes[0].id == survivor_id, (
522+
f"survivor should be smallest-id row {survivor_id}, "
523+
f"got {nodes[0].id}"
524+
)
525+
526+
def test_active_node_and_circle_remap_through_legacy_dup_collapse(
527+
self, client, admin_user, auth_headers, session,
528+
):
529+
"""When the active node OR a circle member is one of the
530+
deleted legacy dups, both must transparently remap to the
531+
surviving sibling — user never notices."""
532+
import asyncio, json
533+
from app.api.subscriptions import _fetch_subscription_unlocked
534+
from app.models import Subscription, Node, NodeCircle
535+
from app.models import Settings as DBSettings
536+
537+
sub = Subscription(name="Test", url="http://example/sub", enabled=True)
538+
session.add(sub)
539+
session.commit()
540+
session.refresh(sub)
541+
542+
# Two dup groups: group A (3 rows, same fingerprint) and
543+
# group B (2 rows, same fingerprint).
544+
a_rows = [
545+
Node(name=f"A{i}", protocol="vless", address="srv-a",
546+
port=443, uuid="uuid-A", transport="tcp", sni=f"sni-a{i}",
547+
subscription_id=sub.id, enabled=True)
548+
for i in range(3)
549+
]
550+
b_rows = [
551+
Node(name=f"B{i}", protocol="vless", address="srv-b",
552+
port=443, uuid="uuid-B", transport="tcp", sni=f"sni-b{i}",
553+
subscription_id=sub.id, enabled=True)
554+
for i in range(2)
555+
]
556+
for r in a_rows + b_rows:
557+
session.add(r)
558+
session.commit()
559+
for r in a_rows + b_rows:
560+
session.refresh(r)
561+
562+
a_survivor = sorted(r.id for r in a_rows)[0]
563+
a_dup = sorted(r.id for r in a_rows)[2] # one of the dups to die
564+
b_survivor = sorted(r.id for r in b_rows)[0]
565+
b_dup = sorted(r.id for r in b_rows)[1]
566+
567+
# Active node pinned at a dup that's about to die
568+
session.add(DBSettings(key="active_node_id", value=str(a_dup)))
569+
# Circle uses one dup of A and one dup of B (both must remap)
570+
circle = NodeCircle(
571+
name="cross-dup", node_ids=json.dumps([a_dup, b_dup]),
572+
mode="sequential", interval_min=5, interval_max=10,
573+
current_index=0, enabled=True,
574+
)
575+
session.add(circle)
576+
session.commit()
577+
session.refresh(circle)
578+
circle_id = circle.id
579+
580+
body = (
581+
"vless://uuid-A@srv-a:443?type=tcp&sni=fresh-a#A\n"
582+
"vless://uuid-B@srv-b:443?type=tcp&sni=fresh-b#B\n"
583+
)
584+
with mock.patch(
585+
"app.api.subscriptions.httpx.AsyncClient"
586+
) as mock_client:
587+
instance = mock_client.return_value.__aenter__.return_value
588+
instance.get = AsyncMock(return_value=mock.Mock(
589+
status_code=200, text=body,
590+
raise_for_status=lambda: None,
591+
))
592+
asyncio.run(_fetch_subscription_unlocked(sub.id))
593+
594+
session.expire_all()
595+
# Active remapped to A's survivor
596+
active = session.query(DBSettings).filter(
597+
DBSettings.key == "active_node_id"
598+
).first()
599+
assert int(active.value) == a_survivor, (
600+
f"active_node_id should remap from dup {a_dup} to "
601+
f"survivor {a_survivor}, got {active.value}"
602+
)
603+
# Circle remapped both members
604+
circle_after = session.get(NodeCircle, circle_id)
605+
assert json.loads(circle_after.node_ids) == [a_survivor, b_survivor]
606+
assert circle_after.enabled is True
607+
608+
425609
class TestSubscriptionRefreshMutex:
426610
"""The endpoint must refuse a second `/refresh` while a previous
427611
one is still in flight. Without this, two clicks within ~100ms

0 commit comments

Comments
 (0)