/polls/voters.json 페이지네이션 요청에서 중복 사용자 반환

/polls/voters.json 엔드포인트를 호출할 때 페이지네이션을 사용하는 경우, 첫 번째 호출에서는 예상대로 25명의 사용자가 반환되지만, 두 번째 페이지에서는 26명의 사용자가 반환됩니다. 이 중 1명은 첫 번째 호출에서도 이미 반환된 사용자입니다. 사용자가 25명을 초과하는 경우 이 문제는 항상 재현됩니다.

문제는 discourse의 poll 플러그인 여기에서 발생합니다:

PostgreSQL의 BETWEEN 구문이 경계값을 포함(inclusive)한다는 점을 고려하지 않고, 경계값을 제외(exclusive)한다고 가정하여 오프셋이 계산되고 있습니다.

첫 번째 쿼리가 실제로 25개를 반환하는 이유는 계산된 행이 0이 아닌 1부터 시작하기 때문입니다.

제가 제안하는 수정 방법은 다음과 같이 간단합니다:

   params = {
      offset: offset + 1,
      offset_plus_limit: offset + limit,
      option_digest: opts[:option_id].presence,
    }

또는, 더 우아한 해결책은 postgres의 LIMIT과 OFFSET을 사용하는 것입니다.

    params = {
      limit: limit,
      offset: offset,
      option_digest: opts[:option_id].presence,
    }
          WHERE pv.poll_id IN (:poll_ids)
                /* where */
        ) v
        ORDER BY digest, CASE WHEN rank = 'Abstain' THEN 1 ELSE CAST(rank AS integer) END, username
		LIMIT :limit OFFSET :offset
      SQL
2개의 좋아요

좋은 지적입니다. 누군가 이 문제를 수정해 보고 싶을 경우를 대비해 pr-welcome 라벨을 달아두겠습니다.

프론트엔드가 여기에 잘못된 가정을 바탕으로 잘못 의존하고 있을 경우, 기존 투표 기능이 깨지지 않는지 확인해야 합니다.

1개의 좋아요

빠른 답변 감사합니다!

환경을 설정해 볼 예정입니다. 테스트 없이 PR을 올리고 싶지 않았거든요. 원하시는 분은 저보다 먼저 진행하셔도 됩니다. 저는 Ruby 개발자가 아니에요. 스펙이나 테스트를 변경하는 것에 대해서도 잘 모르겠습니다.

웃긴 것은 UI에 이 버스를 우회하기 위해 결과를 set에 덤프하는 코드가 있다는 점입니다.

2개의 좋아요

다른 분들도 참고하실 수 있도록, 우리 추첨 소프트웨어에도 비슷한 UI 수정을 적용했습니다.

문제 영역을 찾아준 VHS의 @lukecyca에게 감사의 인사를 전합니다.

1월에 실시된 투표에서 더 많은 응답자를 로드하는 것과 관련된 보고서도 있습니다.

1개의 좋아요

훌륭한 지적입니다.

사실 그보다 더 재미있는 점이 있습니다. 얼마 전 프런트엔드를 현대화하고 랭크드 초이스(Ranked Choice)를 추가하기 위해 꽤 큰 규모의 PR 두 개를 제출한 적이 있습니다.

그 PR들의 방대한 규모가 제가 이 문제를 놓친 데 분명히 기여했고, 백엔드 수정은 사실 범위 내에 있지 않았습니다(다만, 그 PR들을 진행하는 과정에서 해당 PR의 수정 범위를 벗어나는 많은 문제를 실제로 해결하긴 했습니다).

2018년에 정해진 프런트엔드의 선례를 랭크드 초이스에 대해 별다른 생각 없이 그대로 따랐던 것을 인정합니다. :sweat_smile: (여러분도 이를 의식적으로 따랐습니다. :laughing:)

근본적인 문제가 식별되지 않은 채로 우회 해결책이 얼마나 오래 지속될 수 있는지 놀라울 정도입니다.

3개의 좋아요

그래서 첫 기여로 이 문제를 직접 해결해 보려고 했는데, 예상보다 좀 더 까다로운 문제인 것 같습니다. :wink:

현재 Draft PR을 올려 두었습니다: FIX: do not return duplicates from /polls/voters.json - Pull Request #1 - clechasseur/discourse - GitHub

이 PR에서는 먼저 문제를 재현하는 테스트를 추가한 뒤, Rob의 간단한 수정 사항을 적용하여 테스트가 통과하도록 했습니다.

더 우아한 해결책(제가 선호했던 방식)도 시도해 보았는데, 중복 투표자를 방지하는 것은 확실하지만, 어떤 투표자가 어느 페이지에 반환되는지(첫 페이지를 포함하여) 변경됩니다. 이는 프론트엔드에서 이를 어떻게 처리하느냐에 따라 깨지는 변경(breaking change)으로 간주될 수 있습니다(아직 확인은 하지 않았습니다).

한 발짝 물러서서 생각해 보면, 이 엔드포인트를 호출할 때 limit 매개변수의 진정한 의미가 무엇인지 궁금해집니다. limit은 반환되는 전체 투표자 수를 제한하는 것이 아니라, 각 투표 옵션별로 반환되는 투표자 수만을 제한합니다. 제가 멀티 초이스(multi-choice) 투표에 대해 추가한 테스트에서 이 효과를 확인할 수 있습니다 여기 - 첫 페이지는 옵션당 2명의 투표자로 제한되어 있지만, 실제로는 총 3명의 서로 다른 투표자가 반환됩니다(옵션에 분산되어). 우아한 해결책(즉, LIMIT :limit OFFSET :offset 사용)으로 전환하면 limit은 투표자가 아닌 *투표(votes)*의 총 수에 적용됩니다. 이것이 더 나은지 더 직관적인지는 100% 확신하지 못합니다.

어쨌든, 저는 이 프로젝트에 새로 온 것이므로 너무 깊이 생각하고 있을 수 있습니다. 간단한 해결책은 중복 투표자를 제거하고 큰 혼란을 일으키지 않으므로, 이 방향으로 진행하는 것이 좋을 수도 있습니다. 부모 저장소에 PR을 제출하기 전에 피드백을 기다리겠습니다.

–

부연하자면, 이 코드 부분에 또 다른 버그가 있는 것 같습니다. 투표자를 로드하는 쿼리는 digest, rank, username 순서로 정렬되지만, rank로 정렬할 때 다음과 같은 조건을 사용합니다:

CASE WHEN rank = 'Abstain' THEN 1 ELSE CAST(rank AS integer) END

그러나 ’Abstain’은 실제로 rank 1이 아니라 rank 0에 해당합니다. rank 1은 ’1’로 반환될 수도 있습니다. 이로 인해 쿼리 간 정렬이 비결정적(non-deterministic)이 될 수 있으며, 이는 투표자 수와 사용되는 limit 값에 따라 페이지네이션 쿼리 시 실제로 투표자를 놓칠 수 있음을 의미합니다. 제 새로운 테스트에서는 비결정적인 특성을 우회하기 위해 반환된 투표자를 정렬해야 했습니다. (비결정적이므로 테스트에서 재현하기가 쉽지 않겠지만, 시도해 볼 수 있습니다…)

1개의 좋아요

위 링크를 따라 이전 보고서를 확인했는데, 그 안에는 이전 이전 보고서에 대한 좋아요가 포함되어 있습니다. 해당 보고서에는 이 가능성을 가리키는 것으로 보이는 내용도 있습니다:

하지만 그때는 꽤 오래 전이라 당시 코드가 완전히 달랐을 수 있습니다. (Git으로 시간을 거슬러 올라가 보았지만, 히스토리가 2021년에 멈춰 있어서 코드가 어딘가에서 이동한 것으로 추정합니다.)

일부 실험을 해 보았지만, 비결정적 순서가 반환된 투표자(voters)에 문제를 일으키는 테스트를 작성하지 못했습니다. 이는 쿼리가 작성되는 방식과 투표(poll votes)가 생성되는 방식 때문이라고 생각하지만, 100% 확신은 없습니다.

제 테스트와 제안하는 수정 사항을 담은 PR을 생성했습니다: FIX: Avoid returning duplicate voters from `/polls/voters.json` endpoint by clechasseur · Pull Request #34433 · discourse/discourse

3개의 좋아요

수정해 주셔서, 그리고 저희 리뷰를 기다려 주셔서 정말 감사드립니다.

승인하고 병합했습니다. :partying_face:

3개의 좋아요

이 주제는 3일 후 자동으로 닫혔습니다. 더 이상 새 답글을 작성할 수 없습니다.