From e53f44728c6d980ef981ea68017a7265a0f297cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=97=88=EC=9A=A9=EC=84=A0?= Date: Tue, 3 Nov 2026 00:49:34 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Worker=20emit=C2=B7request=EC=97=90=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=9A=9F?= =?UTF-8?q?=EC=88=98=EC=99=80=20=EB=8C=80=EA=B8=B0=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - emit과 request에 재시도 횟수와 대기 간격을 지정하는 선택 인자 추가 - 브로커 호출 실패 시 재시도 로직을 Worker 함수 계층에서 처리 - 기존 동작 유지 및 새로운 기능 선택적 제공 - 관련 이슈: #55 - 이슈 URL: https://github.com/yong5eon/DefStream/issues/55 --- issues/feature/55/README.md | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 issues/feature/55/README.md diff --git a/issues/feature/55/README.md b/issues/feature/55/README.md new file mode 100644 index 0000000..acf8813 --- /dev/null +++ b/issues/feature/55/README.md @@ -0,0 +1,71 @@ +# Worker emit·request에 최대 재시도 횟수와 대기 간격 옵션 추가 + +## 이슈와 작업 상태 + +- 원본: GitHub Issues [#55](https://github.com/yong5eon/DefStream/issues/55) +- 제목: Worker emit·request에 최대 재시도 횟수와 대기 간격 옵션 추가 +- 상태: Open +- 유형·레이블: 기능 (`feature`), 원본 이슈에 적용됨 +- 작업 대상: `yong5eon/DefStream`, 작업 브랜치 `feature/55` +- 분기 기준: 깨끗한 로컬 `master`의 `3ce6044` +- 병합 대상: 미지정 +- 로컬 진행 상태: `feature/55` 브랜치 생성 및 이슈 원문 기록 +- 댓글: 없음 + +## 이슈 원문 + +## 해결하려는 문제 + +Worker에서 제공하는 `emit`과 `request`는 현재 브로커 호출이 실패하면 즉시 예외를 전파한다. 일시적인 실패에 대응하려면 호출자가 각 호출부에서 재시도 로직을 직접 작성해야 한다. + +## 제안하는 해결 방법 + +`src/Worker/Message.py`의 `emit`과 `request`에 재시도 횟수와 시도 간 대기 간격을 지정하는 선택 인자를 추가한다. 브로커 구현체가 아닌 Worker 함수 계층에서 처리한다. + +### 옵션과 기본값 + +| 옵션 | 기본값 | 의미 | +| --- | --- | --- | +| 최대 재시도 횟수 | `None` | 최초 호출을 제외하고 추가로 시도할 최대 횟수 | +| 재시도 간격 | `1`초 | 실패 후 다음 시도 전 대기 시간 | + +인자명은 구현 시 기존 API 명명 관례에 맞춰 확정한다. + +### 동작 + +- 재시도 횟수가 `None`이면 기존과 동일하게 한 번만 호출하고, 실패 시 대기 없이 해당 예외를 즉시 전파한다. +- 재시도 횟수가 `0`이면 추가 시도 없이 최초 호출 결과만 반환하거나 예외를 전파한다. +- 재시도 횟수가 `N`이면 최초 호출 이후 최대 `N`번 추가로 시도한다. 예를 들어 `3`이면 최초 호출을 포함하여 최대 `4`번 호출한다. +- 최초 호출은 즉시 실행하며, 실패 후 다음 시도가 남아 있을 때만 지정한 간격만큼 대기한다. +- 도중에 성공하면 기존 반환값을 즉시 반환하고 추가로 시도하지 않는다. +- 모든 시도가 실패하면 마지막 시도에서 발생한 예외를 다른 예외로 감싸거나 대체하지 않고 그대로 `raise`한다. 마지막 실패 뒤에는 대기하지 않는다. +- `emit`과 `request`에 동일한 재시도 정책을 적용한다. 기존 인자와 브로커 호출의 반환값·예외 계약은 유지한다. +- `request`의 기존 `timeout`은 각 브로커 요청 호출에 그대로 전달한다. 전체 재시도 과정의 총 제한 시간으로 바꾸지 않는다. + +### 구현 범위 + +- Worker의 `emit`·`request`에서 브로커 호출 실패에 대한 재시도를 제공한다. +- 브로커 구현체 및 브로커 인터페이스에 재시도 옵션이나 내부 재시도 기능을 추가하지 않는다. +- Worker의 기존 입력 검증과 지원 기능 확인은 유지하고, 이를 브로커 호출 재시도 대상으로 확대하지 않는다. +- 재시도 미설정 시 기존 동작을 유지한다. + +## 고려한 대안 + +- 브로커별 구현체에 재시도를 구현하는 대신, 호출자가 사용하는 Worker 함수 계층에서 공통으로 제공한다. + +## 인수 기준 + +- [ ] `emit`과 `request` 모두 최대 재시도 횟수와 대기 간격을 지정할 수 있으며, 기본값은 각각 `None`과 `1`초이다. +- [ ] `None` 또는 `0`이면 한 번만 호출하고 실패 예외를 즉시 전파한다. +- [ ] 재시도 횟수 `3`에서 계속 실패하면 총 `4`회 호출하고 대기는 `3`회만 수행한다. +- [ ] 도중에 성공하면 반환값을 그대로 반환하며 남은 시도와 대기를 수행하지 않는다. +- [ ] 지정한 간격과 기본 간격이 실제 재시도 대기에 적용된다. +- [ ] 최종 실패 시 마지막 예외 객체와 타입을 그대로 전파하며, 별도 예외로 변환하지 않는다. +- [ ] 기존 `priority` 전달과 `request`의 `timeout` 전달을 유지한다. +- [ ] 브로커 구현체에 재시도 기능을 추가하지 않고 두 Worker 함수에서 검증한다. + +## 추가 정보 + +기존 동작을 바꾸는 것이 아니라 선택적으로 사용할 수 있는 공개 기능을 추가하므로 `feature`로 분류한다. + +재시도는 중복 전송이나 중복 실행 방지를 보장하지 않는다. 전송 이후 응답 대기 중 실패하는 등의 경우에는 이미 처리된 요청이 다시 전송될 수 있음을 사용 문서에 안내한다. 이 이슈에서 별도의 중복 방지 기능은 추가하지 않는다. From a18cad3e47e62991b83c3864ded1b9cb54cdf407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=97=88=EC=9A=A9=EC=84=A0?= Date: Tue, 3 Nov 2026 20:44:37 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Worker=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=C2=B7=EC=9A=94=EC=B2=AD=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker의 `emit()`과 `request()`에 대한 재시도 요구사항을 정의한 문서 추가 - 작업 계획 문서 작성 및 요구사항 명세에 링크 추가 - 작업 종합 문서 작성 및 단계별 기록 포함 - 이슈 #55의 요구사항 분석 및 검증 계획 작성 - 관련 이슈: #55 - 이슈 URL: https://github.com/yong5eon/DefStream/issues/55 --- issues/feature/55/PLAN.md | 76 ++++++++++++++++++++++++ issues/feature/55/README.md | 9 ++- issues/feature/55/REQUIREMENTS.md | 67 +++++++++++++++++++++ issues/feature/55/TASKS.md | 17 ++++++ issues/feature/55/tasks/1793705387888.md | 7 +++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 issues/feature/55/PLAN.md create mode 100644 issues/feature/55/REQUIREMENTS.md create mode 100644 issues/feature/55/TASKS.md create mode 100644 issues/feature/55/tasks/1793705387888.md diff --git a/issues/feature/55/PLAN.md b/issues/feature/55/PLAN.md new file mode 100644 index 0000000..2949f69 --- /dev/null +++ b/issues/feature/55/PLAN.md @@ -0,0 +1,76 @@ +# Worker 메시지 발행·요청 재시도 계획 + +작업 범위와 진행 상태는 [이슈 기록](./README.md), 목표 동작과 미확정 사항은 [요구사항 명세](./REQUIREMENTS.md)를 따릅니다. 현재는 분석·계획 단계이며 구현과 검증은 시작하지 않았습니다. + +## 수행 단계와 진행 상태 + +### 1. 공개 인자와 입력 계약 확정 + +상태: 대기 + +- `emit()`·`request()`의 키워드 전용 인자명을 확정하고 타입·기본값을 일치시킵니다. `max_retries`와 `retry_interval`을 우선 검토하되, 기존 `recovery_attempts`·`recovery_interval` 명명과 함께 확인합니다. +- 재시도 횟수와 간격의 허용 타입·범위를 정합니다. 기존 `Worker`의 횟수·간격 검증 관례를 참고하고, 잘못된 입력은 첫 브로커 호출 전에 명시적으로 거부합니다. +- 재시도 범위가 모든 브로커 호출 예외, `RemoteError` 포함이라는 결정을 유지합니다. + +대응: R-01·R-04, AC-05·AC-07 + +### 2. Worker 계층 재시도 구현 + +상태: 대기 / 선행: 1단계 + +대상: [Message.py](../../../src/Worker/Message.py) + +- 기존 입력·지원 기능 확인과 `priority_parameters()` 처리는 재시도 루프 바깥에 유지합니다. +- `emit()`과 `request()`의 실제 브로커 호출에 동일한 횟수·간격 정책을 적용합니다. +- 성공값을 그대로 반환하고, 실패 후 시도가 남아 있는 때만 대기하며, 최종 예외를 변환하지 않고 다시 발생시킵니다. +- 기존 `request(timeout=...)` 및 우선순위 호출 경로의 전달값을 유지합니다. +- 브로커 Protocol·구현체와 연결 소유권은 변경하지 않습니다. + +대응: R-02~R-08, AC-01~AC-07 + +### 3. 단위 회귀 테스트 + +상태: 대기 / 선행: 2단계 + +대상: [test_task_messages.py](../../../tests/test_task_messages.py) + +- Mock 브로커의 성공·실패 순서를 제어해 최초 성공, 중간 성공, 최대 횟수 실패, 마지막 예외 객체 보존을 검증합니다. +- `sleep` 등 실제 시간 대기는 Mock 처리하고, 기본·사용자 지정 간격 및 대기 횟수를 확인합니다. +- `emit()`·`request()` 각각에서 `priority`와 `timeout`이 모든 호출에 유지되는지 확인합니다. +- 기존 입력·기능 지원 검증이 브로커 호출 전에 실패하고 재시도하지 않는지 확인합니다. + +대응: AC-01~AC-08 + +### 4. 매뉴얼과 고객용 변경 로그 갱신 + +상태: 대기 / 선행: 2·3단계 + +- [한국어 작업 매뉴얼](../../../docs/Tasks.md)과 [영어 작업 매뉴얼](../../../docs/Tasks.en.md)에 API 옵션·사용 예·기본값·시도 횟수 의미를 함께 반영합니다. +- 호출 재시도와 소비자 메시지 재전달을 구분하고, `RemoteError` 포함 재시도가 중복 요청·작업 실행을 일으킬 수 있음을 설명합니다. +- 구현이 완료된 뒤에만 실제 공개 동작을 기준으로 이슈별 `CHANGELOG.md`를 작성합니다. 분석·계획 단계에서는 배포된 기능처럼 변경 로그에 기록하지 않습니다. + +대응: R-09, AC-09 + +### 5. 검증 및 완료 확인 + +상태: 대기 / 선행: 2~4단계 + +- 저장소의 focused unittest 명령으로 메시지 API 회귀 테스트를 실행합니다. +- 변경된 문서와 인수 기준의 일치, 한국어·영어 매뉴얼 간 내용 일치, 새 예제의 실제 공개 API 적합성을 확인합니다. +- 검증하지 않은 실제 브로커 장애·전송 후 중복 처리 동작은 테스트 결과로 주장하지 않고 제한사항으로 남깁니다. +- 실제 수행 결과를 작업 기록 및 `TASKS.md`에 반영하고, 구현이 발생하면 `IMPLEMENTS.md`와 구현 단계별 기록도 별도로 작성합니다. + +## 검증 계획 + +| 범위 | 방법 | +| --- | --- | +| Worker 메시지 API 회귀 | 저장소 루트에서 `python -m unittest tests.test_task_messages -v` 실행 | +| 재시도 횟수·대기 | Mock 브로커와 Mock 대기 함수로 호출 수·대기 인자·대기 횟수 확인 | +| 기존 전달 계약 | Mock 호출 인자로 본문·작업 설정·priority·timeout 확인 | +| 문서 계약 | `Tasks.md`·`Tasks.en.md`의 API 설명·예·중복 처리 주의사항을 요구사항과 대조 | + +## 계획의 한계 + +- 모든 브로커 호출 예외를 재시도하므로 일시적인 전송 오류뿐 아니라 `RemoteError`와 같은 영구적 원격 작업 오류도 반복할 수 있습니다. 이 범위는 사용자 결정이며, 중복 방지는 별도 범위입니다. +- 실제 브로커 통합 검증은 단위 테스트와 별개입니다. 구현 시 로컬 브로커 환경이 준비되지 않았다면 실행하지 않은 사실을 기록합니다. +- 현재 이 계획의 테스트·문서 갱신·구현은 실행되지 않았습니다. diff --git a/issues/feature/55/README.md b/issues/feature/55/README.md index acf8813..2a5ed8e 100644 --- a/issues/feature/55/README.md +++ b/issues/feature/55/README.md @@ -9,9 +9,16 @@ - 작업 대상: `yong5eon/DefStream`, 작업 브랜치 `feature/55` - 분기 기준: 깨끗한 로컬 `master`의 `3ce6044` - 병합 대상: 미지정 -- 로컬 진행 상태: `feature/55` 브랜치 생성 및 이슈 원문 기록 +- 로컬 진행 상태: 이슈 분석·요구사항·검증 계획 작성 완료, 구현 미착수 - 댓글: 없음 +## 작업 문서 + +- [요구사항 명세](./REQUIREMENTS.md) +- [작업 계획](./PLAN.md) +- [작업 종합](./TASKS.md) +- [최신 작업 기록](./tasks/1793705387888.md) + ## 이슈 원문 ## 해결하려는 문제 diff --git a/issues/feature/55/REQUIREMENTS.md b/issues/feature/55/REQUIREMENTS.md new file mode 100644 index 0000000..758ee2d --- /dev/null +++ b/issues/feature/55/REQUIREMENTS.md @@ -0,0 +1,67 @@ +# Worker 메시지 발행·요청 재시도 요구사항 + +## 목적 + +Worker의 `emit()`과 `request()`에 선택적 재시도를 추가하여 브로커 호출 실패를 설정된 횟수까지 다시 시도합니다. 이슈 원문과 작업 브랜치 정보는 [이슈 기록](./README.md)을 기준으로 합니다. 이 문서는 요청 내용과 현재 구현을 대조한 분석 및 목표 동작을 정의하며, 구현·검증 결과는 아직 포함하지 않습니다. + +## 분석과 근거 + +### 이슈에서 요청한 결과 + +- `emit()`과 `request()`에서 최대 재시도 횟수와 시도 간 대기 시간을 선택적으로 지정합니다. +- 기본 최대 재시도 횟수는 `None`으로, 재시도하지 않고 기존처럼 한 번만 호출합니다. 기본 대기 간격은 1초입니다. +- 재시도 횟수는 최초 호출 이후의 추가 시도 횟수입니다. 실패 후 다음 시도가 있을 때만 대기하고, 성공하면 즉시 반환하며, 마지막 실패 뒤에는 대기하지 않습니다. +- 모든 시도가 실패하면 마지막 호출에서 난 예외를 그대로 전파합니다. +- 기존 `request()`의 `timeout`, `priority` 전달과 Worker 계층에서 재시도를 제공한다는 범위를 유지합니다. + +### 현재 코드와 요구사항의 차이 + +- [Worker 메시지 함수](../../../src/Worker/Message.py)의 `emit()`은 작업·본문·브로커 기능·우선순위를 확인한 뒤 `broker.emit()`을 한 번 호출합니다. `request()`도 같은 순서로 입력을 확인한 뒤 `broker.request()`를 한 번 호출하고, 매번 기존 `timeout`을 전달합니다. +- 입력 검증과 지원 기능 확인은 브로커 호출보다 앞서 수행됩니다. 재시도는 브로커 메서드 호출에만 적용하고, 이 검증을 반복하거나 `NotImplementedError`를 재시도 대상으로 만들 필요는 없습니다. +- 공통 [브로커 연결 계약](../../../src/Broker/Connection.py)은 발행·요청 지원 여부를 Protocol로 표현하지만, 재시도 가능한 예외를 공통 분류하는 계약은 없습니다. 이슈의 요청대로 재시도 동작은 Worker API 계층에 둡니다. +- `request()`를 지원하지 않는 연결은 `Requestable` 검사에서 브로커 I/O 전에 거부됩니다. Redis 연결은 현재 `Requestable`을 구현하지 않습니다. +- RabbitMQ의 [RPC 응답 처리](../../../src/Broker/Implements/RabbitMQ/Queue.py)는 원격 작업 오류 응답을 `RemoteError`로 던집니다. 사용자는 재시도 범위에 브로커 호출에서 발생하는 모든 예외를 포함하도록 확정했습니다. 따라서 `RemoteError`도 재시도되며, 실패한 원격 작업이 다시 실행될 수 있습니다. +- 기존 [작업 메시지 테스트](../../../tests/test_task_messages.py)는 Mock 브로커로 Worker 함수의 호출 횟수·인자·오류 전파를 검증할 수 있습니다. 실제 브로커에 의존하지 않고 재시도 횟수와 대기를 검증할 수 있습니다. +- [작업 매뉴얼](../../../docs/Tasks.md)과 영어판은 현재 소비 메시지의 오류 정책·재전달을 설명합니다. 새 API 재시도와 소비자 재전달이 서로 다른 동작임을 구분하여 두 문서를 함께 갱신해야 합니다. + +## 확정 요구사항 + +| ID | 요구사항 | +| --- | --- | +| R-01 | `emit()`과 `request()`에 최대 재시도 횟수와 재시도 간격을 선택 인자로 제공해야 합니다. 기본값은 재시도 횟수 `None`, 간격 1초입니다. 공개 인자명은 구현 계획에서 기존 명명 관례에 맞춰 확정합니다. | +| R-02 | `None` 또는 `0`회 재시도는 최초 호출만 수행합니다. `N`회 재시도는 최초 호출 후 최대 `N`회 추가 호출까지 허용합니다. | +| R-03 | 최초 호출은 대기 없이 수행합니다. 실패 후 재시도가 남은 경우에만 지정 간격을 기다립니다. 성공하면 즉시 반환하고, 마지막 실패 후에는 대기하지 않습니다. | +| R-04 | 브로커의 `emit()` 또는 `request()` 호출이 예외를 던지면 재시도 정책을 적용합니다. 사용자가 확정한 범위에는 `RemoteError`를 포함한 브로커 호출의 모든 일반 예외가 포함됩니다. 입력·작업 검증 및 기능 지원 확인 단계에서 발생하는 오류는 재시도하지 않습니다. | +| R-05 | 어느 시도에서든 성공하면 해당 호출의 기존 반환값을 그대로 반환합니다. 모든 시도가 실패하면 마지막 시도의 예외 객체와 타입을 바꾸지 않고 전파합니다. | +| R-06 | `request()`의 기존 `timeout`은 각 재시도 호출에 동일하게 전달합니다. 재시도 전체 시간에 대한 별도 총 timeout은 추가하지 않습니다. 기존 `priority`, 본문과 작업 매개변수 전달도 유지합니다. | +| R-07 | 재시도는 `DefStream.Worker`의 `emit()`·`request()`에만 적용합니다. 브로커 Protocol·구현체에는 재시도 설정이나 중복 재시도를 추가하지 않습니다. | +| R-08 | 기존 동작을 유지하면서 재시도가 기본 비활성화된 선택 기능으로 동작해야 합니다. 입력 검증·브로커 소유권·연결 수명주기 계약은 바꾸지 않습니다. | +| R-09 | 문서에서 `emit()`/`request()` 호출 재시도와 소비 중 메시지 오류·재전달 정책을 구분하고, 이미 처리된 발행·요청이 다시 전송될 수 있음을 안내합니다. | + +## 구현 전 구체화할 사항 + +- 공개 인자명은 `max_retries`와 `retry_interval` 조합을 우선 검토합니다. 기존 `Worker`의 `recovery_attempts`·`recovery_interval`과 이름을 맞추는 대안도 있으므로, 실제 시그니처를 정할 때 일관성을 최종 확인합니다. +- 저장소의 기존 설정 검증은 횟수에서 `bool`과 음수를 거부하고, 간격에서 `bool`·비유한 값·음수를 거부합니다. 새 인자도 같은 계약을 적용할지 구현 전에 확인하고 테스트로 고정해야 합니다. 이슈 원문은 잘못된 값의 오류 타입을 별도로 정하지 않았습니다. +- `RemoteError`까지 재시도하면 원격 작업 오류 응답을 받은 경우에도 해당 요청을 다시 실행할 수 있습니다. 이 동작은 사용자가 선택한 예외 범위에 따른 의도된 결과이며, 멱등성이나 중복 방지는 이 요구사항에 포함되지 않습니다. + +## 인수 기준 + +아래 기준은 구현 후 확인할 목표이며, 현재 통과했다는 의미가 아닙니다. + +| ID | 검증 기준 | +| --- | --- | +| AC-01 | 기본값에서는 `emit()`과 `request()`가 한 번만 호출되고 재시도 대기가 없습니다. | +| AC-02 | 재시도 횟수 `0`은 한 번, `N`은 최대 `N + 1`번 호출합니다. | +| AC-03 | 계속 실패하는 `N`회 재시도는 `N`회 대기만 수행하며, 마지막 실패 후에는 대기하지 않습니다. | +| AC-04 | 중간 시도에서 성공하면 반환값을 보존하고 후속 호출·대기를 하지 않습니다. | +| AC-05 | 계속 실패하면 마지막 예외 객체를 그대로 전파합니다. `RemoteError`도 지정 횟수까지 재시도합니다. | +| AC-06 | 두 함수의 재시도에서 `priority`·요청 `timeout`·본문·작업 매개변수 전달이 기존 계약과 일치합니다. | +| AC-07 | 작업·본문·브로커 기능 검증 실패는 브로커 메서드를 호출하지 않고 재시도도 하지 않습니다. | +| AC-08 | 단위 테스트에서 실제 대기 없이 기본·사용자 지정 간격과 정확한 대기 횟수를 검증합니다. | +| AC-09 | 한국어·영어 작업 매뉴얼에 공개 API, 사용 예, 기본 동작 및 중복 실행 주의사항을 일관되게 반영합니다. | + +## 범위 제외 + +- 브로커별 재시도, 오류 종류별 backoff·jitter, 재시도 전체 deadline, 자동 연결 재생성은 추가하지 않습니다. +- 멱등 키, 중복 요청 제거, exactly-once 처리 또는 중복 실행 방지는 보장하지 않습니다. +- 소비자 측 ACK·NACK·메시지 재전달 정책은 변경하지 않습니다. diff --git a/issues/feature/55/TASKS.md b/issues/feature/55/TASKS.md new file mode 100644 index 0000000..f26488d --- /dev/null +++ b/issues/feature/55/TASKS.md @@ -0,0 +1,17 @@ +# 작업 종합 + +## 결과 + +이슈 #55의 요구를 현재 Worker API, Broker Protocol, 메시지 테스트와 작업 매뉴얼에 대조해 분석했다. `emit()`·`request()` 호출에만 재시도를 적용하고 입력 검증·지원 기능 확인은 재시도 바깥에 두는 방향을 정리했다. + +사용자 선택에 따라 브로커 호출에서 발생하는 모든 예외를 재시도하며 `RemoteError`도 포함한다. 따라서 재시도 도중 동일 작업이나 요청이 반복될 수 있다는 제한과 문서 반영 필요성을 명시했다. + +## 단계별 기록 + +- [이슈 #55 분석 및 검증 계획 작성](./tasks/1793705387888.md) + +## 미완료 사항 + +- 재시도 공개 인자명과 잘못된 횟수·간격 입력의 오류 계약은 구현 전에 확정해야 한다. +- 계획된 구현, focused unittest, 한국어·영어 매뉴얼 갱신 및 최종 검증은 아직 수행하지 않았다. +- 고객용 변경 로그는 기능 구현이 완료된 뒤 실제 동작을 기준으로 작성한다. diff --git a/issues/feature/55/tasks/1793705387888.md b/issues/feature/55/tasks/1793705387888.md new file mode 100644 index 0000000..6bdb31f --- /dev/null +++ b/issues/feature/55/tasks/1793705387888.md @@ -0,0 +1,7 @@ +# 이슈 #55 분석 및 검증 계획 작성 + +이슈 원문을 [Worker 메시지 함수](../../../../src/Worker/Message.py), 브로커 호출 계약, 기존 메시지 테스트와 한국어·영어 작업 매뉴얼에 대조했다. `emit()`과 `request()`는 현재 검증 후 각 브로커 메서드를 한 번 호출하며, `request()`의 `timeout`·priority 전달은 호출 경로에 유지되어야 한다. + +사용자 결정에 따라 브로커 호출에서 발생하는 모든 예외를 재시도 범위에 포함하고 `RemoteError`도 제외하지 않는다. 이에 따라 원격 작업이 이미 실행된 뒤 오류 응답을 받은 경우에도 요청이 반복될 수 있음을 요구사항과 향후 매뉴얼 안내 사항으로 기록했다. + +`REQUIREMENTS.md`에 현재 동작·요구사항·인수 기준과 구현 전 구체화할 인자 검증 사항을 정리하고, `PLAN.md`에 구현·단위 테스트·한영 매뉴얼·검증 순서를 작성했다. 구현, 테스트 및 매뉴얼 갱신은 아직 수행하지 않았다. From 2494e1674c8c67c8aa7390dab060d63e6adb5cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=97=88=EC=9A=A9=EC=84=A0?= Date: Tue, 3 Nov 2026 23:23:41 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Worker=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=C2=B7=EC=9A=94=EC=B2=AD=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `emit()`·`request()`에 `retry`·`interval` 옵션 추가 - 입력 검증, 회귀 테스트 및 한영 매뉴얼 반영 - 관련 이슈: #55 - 이슈 URL: https://github.com/yong5eon/DefStream/issues/55 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/Tasks.en.md | 42 +++- docs/Tasks.md | 40 +++- issues/feature/55/CHANGELOG.md | 5 + issues/feature/55/IMPLEMENTS.md | 23 ++ issues/feature/55/PLAN.md | 41 ++-- issues/feature/55/README.md | 7 +- issues/feature/55/REQUIREMENTS.md | 10 +- issues/feature/55/TASKS.md | 13 +- issues/feature/55/implements/1793714914193.md | 7 + issues/feature/55/tasks/1793714914194.md | 9 + issues/feature/55/tasks/1793715537355.md | 5 + src/Worker/Message.py | 105 +++++++-- tests/test_task_messages.py | 221 +++++++++++++++++- 13 files changed, 475 insertions(+), 53 deletions(-) create mode 100644 issues/feature/55/CHANGELOG.md create mode 100644 issues/feature/55/IMPLEMENTS.md create mode 100644 issues/feature/55/implements/1793714914193.md create mode 100644 issues/feature/55/tasks/1793714914194.md create mode 100644 issues/feature/55/tasks/1793715537355.md diff --git a/docs/Tasks.en.md b/docs/Tasks.en.md index 9215866..0fa2089 100644 --- a/docs/Tasks.en.md +++ b/docs/Tasks.en.md @@ -11,8 +11,8 @@ Task APIs, delivery policies, pools, and lifecycle hooks. | Function | Purpose | Connection ownership | | --- | --- | --- | | `declare(task=..., broker=...)` | Declare task queues and subscription resources | Caller connects and closes | -| `emit(body, task=..., broker=...)` | Publish a one-way message to a task | Caller connects and closes | -| `request(body, task=..., broker=..., timeout=5)` | Request a task result synchronously | Caller connects and closes; RabbitMQ only | +| `emit(body, task=..., broker=..., retry=None, interval=1)` | Publish a one-way message to a task | Caller connects and closes | +| `request(body, task=..., broker=..., timeout=5, retry=None, interval=1)` | Request a task result synchronously | Caller connects and closes; RabbitMQ only | Import these functions from `DefStream.Worker`. They do not connect or close the supplied connection. Neither `emit()` nor `request()` declares destinations. @@ -118,6 +118,42 @@ result = request( - Priority affects delivery of waiting messages within the same queue. It does not preempt delivered or running tasks or guarantee execution and completion order. +### Publication and request retries + +`emit()` and `request()` can optionally retry a broker call that raises an exception. +The default `retry=None` preserves the existing single-call behavior. The value +counts additional attempts after the first call, so `retry=3` allows up to four +calls. The default `interval` is one second; it is applied only after a failure +when another attempt remains. `retry` must be `None` or a non-negative integer +other than `bool`, and `interval` must be a finite non-negative number other +than `bool`. Invalid values are rejected before the broker call. + +```python +emit( + {'a': 10, 'b': 20}, + task=Add, + broker=broker, + retry=3, + interval=0.5, +) + +# The request timeout applies to each call independently of the retry count. +result = request( + {'a': 10, 'b': 20}, + task=Add, + broker=broker, + timeout=5, + retry=2, +) +``` + +A successful attempt returns immediately. If every attempt fails, the final exception +is re-raised. Exceptions raised by broker `emit()` and `request()` calls are retried, +including `RemoteError`; therefore a request may run again even if its remote task +already executed before returning an error. Retries do not prevent duplicate +publication or execution, so tasks using retries should be safe to run more than once. +Invalid inputs and unsupported broker operations are not retried. + ### Success, failures, and retries - ACK follows successful execution and completion hooks. RabbitMQ RPC also publishes the reply before ACK. @@ -125,7 +161,7 @@ result = request( - `Policy` is the shared broker contract. Import `Requeue` and `Reject` from the selected broker package; behavior can differ by broker. - Rejection is broker-specific: RabbitMQ dead-letters to a configured DLQ or discards the message; Redis acknowledges it for the group while preserving stream history. -- There is no automatic retry-count limit. Choose a policy that avoids endless redelivery of permanently invalid messages. +- Consumer message redelivery has no automatic retry-count limit. Choose a policy that avoids endless redelivery of permanently invalid messages. - Publication and ACK are not one transaction. **Make tasks idempotent and safe to execute more than once.** The common resource operations below apply when accessing a broker directly. diff --git a/docs/Tasks.md b/docs/Tasks.md index a67f47c..7b6642d 100644 --- a/docs/Tasks.md +++ b/docs/Tasks.md @@ -11,8 +11,8 @@ | 함수 | 역할 | 연결 관리 | | --- | --- | --- | | `declare(task=..., broker=...)` | 작업의 큐·구독 리소스 선언 | 호출자가 연결·종료 | -| `emit(body, task=..., broker=...)` | 작업 목적지로 단방향 메시지 발행 | 호출자가 연결·종료 | -| `request(body, task=..., broker=..., timeout=5)` | 작업 결과를 동기 RPC로 요청 | 호출자가 연결·종료; RabbitMQ만 지원 | +| `emit(body, task=..., broker=..., retry=None, interval=1)` | 작업 목적지로 단방향 메시지 발행 | 호출자가 연결·종료 | +| `request(body, task=..., broker=..., timeout=5, retry=None, interval=1)` | 작업 결과를 동기 RPC로 요청 | 호출자가 연결·종료; RabbitMQ만 지원 | 세 함수는 `DefStream.Worker`에서 가져옵니다. 전달된 연결을 자동으로 열거나 닫지 않으며, `emit()`와 `request()`는 목적지를 자동 선언하지 않습니다. @@ -116,6 +116,40 @@ result = request( - 우선순위는 같은 큐의 대기 메시지 전달에 적용됩니다. 이미 전달·실행 중인 작업을 선점하거나 여러 작업의 실행·완료 순서를 보장하지 않습니다. +### 발행·요청 재시도 + +`emit()`과 `request()`는 브로커 호출이 예외로 실패하면 선택적으로 다시 시도할 수 있습니다. +기본 `retry=None`은 기존처럼 한 번만 호출합니다. 횟수는 최초 시도 이후의 +추가 횟수이므로 `retry=3`은 최대 네 번 호출합니다. 실패 후 다음 시도가 남았을 때만 +`interval`초를 기다리며 기본 간격은 1초입니다. +`retry`는 `None` 또는 bool이 아닌 0 이상의 정수이고, `interval`은 bool이 아닌 +유한한 0 이상의 숫자여야 합니다. 잘못된 값은 브로커 호출 전에 거부합니다. + +```python +emit( + {'a': 10, 'b': 20}, + task=Add, + broker=broker, + retry=3, + interval=0.5, +) + +# 요청의 timeout은 재시도 횟수와 별도로 매 호출에 적용됩니다. +result = request( + {'a': 10, 'b': 20}, + task=Add, + broker=broker, + timeout=5, + retry=2, +) +``` + +중간 호출이 성공하면 결과를 바로 반환하고, 모든 호출이 실패하면 마지막 예외를 그대로 +전파합니다. `RemoteError`를 포함해 브로커의 발행·요청 호출에서 발생한 예외를 재시도하므로, +원격 작업이 이미 실행된 뒤 오류 응답을 받은 요청도 다시 실행될 수 있습니다. 중복 전송이나 +작업 실행을 방지하지 않으므로 재시도가 필요한 작업은 중복 실행에 안전하도록 설계하세요. +잘못된 입력이나 브로커 기능 미지원 오류는 재시도하지 않습니다. + ### 성공, 실패와 재시도 - 작업 실행과 완료 훅이 성공한 뒤 ACK합니다. RabbitMQ RPC는 응답 발행도 ACK보다 먼저 수행합니다. @@ -124,7 +158,7 @@ result = request( 패키지에서 가져오며, 같은 이름의 정책도 동작은 브로커마다 다를 수 있습니다. - `Reject()`의 실제 효과는 브로커에 따라 다릅니다. RabbitMQ는 설정된 DLQ로 보내거나 폐기하며, Redis는 해당 그룹에서 ACK하고 스트림 이력은 유지합니다. -- 재시도 횟수의 자동 제한은 없습니다. 영구적인 오류가 무한 재전달되지 않도록 정책을 선택하세요. +- 소비자 측 메시지 재전달 횟수에는 자동 제한이 없습니다. 영구적인 오류가 무한 재전달되지 않도록 정책을 선택하세요. - 전송과 ACK는 하나의 트랜잭션이 아닙니다. **중복 실행에 안전한 멱등 작업**을 작성해야 합니다. 브로커 리소스를 직접 사용할 때의 공통 메서드는 다음과 같습니다. diff --git a/issues/feature/55/CHANGELOG.md b/issues/feature/55/CHANGELOG.md new file mode 100644 index 0000000..43299db --- /dev/null +++ b/issues/feature/55/CHANGELOG.md @@ -0,0 +1,5 @@ +# Worker 메시지 발행·요청 재시도 + +`emit()`과 `request()`에 `retry`와 `interval` 옵션을 추가했습니다. 기본 설정은 기존처럼 한 번만 호출하며, 재시도 횟수를 설정하면 실패한 브로커 호출을 지정 횟수까지 다시 시도합니다. `request()`의 `timeout`은 각 호출에 적용됩니다. + +브로커 호출에서 발생한 `RemoteError`를 포함한 예외도 재시도 대상입니다. 원격 작업이 이미 실행된 뒤 오류 응답을 받은 요청도 다시 실행될 수 있으므로, 중복 요청이나 부작용에 안전한 작업에만 재시도를 설정하세요. diff --git a/issues/feature/55/IMPLEMENTS.md b/issues/feature/55/IMPLEMENTS.md new file mode 100644 index 0000000..ba1efeb --- /dev/null +++ b/issues/feature/55/IMPLEMENTS.md @@ -0,0 +1,23 @@ +# Worker 메시지 발행·요청 재시도 구현 + +## 결과 + +[요구사항 명세](./REQUIREMENTS.md)에 따라 [Worker 메시지 함수](../../../src/Worker/Message.py)의 `emit()`과 `request()`에 `retry`와 `interval` 키워드 인자를 추가했습니다. 기본값은 각각 `None`과 1초입니다. `None` 또는 `0`은 한 번 호출하며, `N`은 최초 호출 후 최대 `N`번 추가 호출합니다. + +입력 설정은 브로커 호출 전에 검증합니다. 재시도는 브로커의 `emit()`·`request()` 호출에만 적용하고, 실패 후 시도가 남은 때만 대기하며, 최종 예외를 그대로 전파합니다. `request()`의 `timeout`과 두 API의 기존 우선순위 전달을 재시도마다 유지합니다. 모든 `Exception`을 대상으로 하므로 `RemoteError`도 재시도되며, 중복 요청·작업 실행 방지는 제공하지 않습니다. + +브로커 인터페이스나 구현체, 연결 생성·종료 동작은 변경하지 않았습니다. + +초기 구현 후 공개 인자명을 `retry`와 `interval`로 변경했습니다. 현재 API와 문서에는 이전 이름을 남기지 않았습니다. + +## 검증 + +- [메시지 API 회귀 테스트](../../../tests/test_task_messages.py): 13개 테스트 통과. 재시도 횟수·대기, 기본값·0회, 중간 성공, 최종 예외 객체 보존, `RemoteError`, priority·timeout, 입력 검증을 확인했습니다. +- Pylance 함수 시그니처 호환성 검사에서 `emit()`·`request()`의 기존 호출부가 각각 호환됨을 확인했습니다. +- Pylance 진단에서 오류가 없고, 변경한 Python 소스와 테스트의 syntax 검사도 통과했습니다. +- 한국어·영어 작업 매뉴얼과 고객용 [변경 로그](./CHANGELOG.md)를 갱신했습니다. +- 실제 RabbitMQ·Redis 연결 장애에 대한 통합 검증은 수행하지 않았습니다. + +## 단계별 기록 + +- [Worker 재시도 구현](./implements/1793714914193.md) diff --git a/issues/feature/55/PLAN.md b/issues/feature/55/PLAN.md index 2949f69..f8ead32 100644 --- a/issues/feature/55/PLAN.md +++ b/issues/feature/55/PLAN.md @@ -1,22 +1,22 @@ # Worker 메시지 발행·요청 재시도 계획 -작업 범위와 진행 상태는 [이슈 기록](./README.md), 목표 동작과 미확정 사항은 [요구사항 명세](./REQUIREMENTS.md)를 따릅니다. 현재는 분석·계획 단계이며 구현과 검증은 시작하지 않았습니다. +작업 범위와 진행 상태는 [이슈 기록](./README.md), 목표 동작과 미확정 사항은 [요구사항 명세](./REQUIREMENTS.md)를 따릅니다. 구현과 대상 단위 테스트, 한국어·영어 작업 매뉴얼 갱신을 완료했습니다. 최종 검증 결과와 남은 제한은 [작업 종합](./TASKS.md) 및 [구현 종합](./IMPLEMENTS.md)에 반영했습니다. ## 수행 단계와 진행 상태 ### 1. 공개 인자와 입력 계약 확정 -상태: 대기 +상태: 완료 -- `emit()`·`request()`의 키워드 전용 인자명을 확정하고 타입·기본값을 일치시킵니다. `max_retries`와 `retry_interval`을 우선 검토하되, 기존 `recovery_attempts`·`recovery_interval` 명명과 함께 확인합니다. -- 재시도 횟수와 간격의 허용 타입·범위를 정합니다. 기존 `Worker`의 횟수·간격 검증 관례를 참고하고, 잘못된 입력은 첫 브로커 호출 전에 명시적으로 거부합니다. +- `emit()`·`request()`의 키워드 전용 인자는 `retry`와 `interval`로 확정했습니다. +- 재시도 횟수는 `None` 또는 bool이 아닌 0 이상의 정수, 간격은 bool이 아닌 유한한 0 이상의 정수·실수로 제한하고, 브로커 호출 전에 검증합니다. - 재시도 범위가 모든 브로커 호출 예외, `RemoteError` 포함이라는 결정을 유지합니다. 대응: R-01·R-04, AC-05·AC-07 ### 2. Worker 계층 재시도 구현 -상태: 대기 / 선행: 1단계 +상태: 완료 / 선행: 1단계 대상: [Message.py](../../../src/Worker/Message.py) @@ -30,7 +30,7 @@ ### 3. 단위 회귀 테스트 -상태: 대기 / 선행: 2단계 +상태: 완료 / 선행: 2단계 대상: [test_task_messages.py](../../../tests/test_task_messages.py) @@ -43,34 +43,35 @@ ### 4. 매뉴얼과 고객용 변경 로그 갱신 -상태: 대기 / 선행: 2·3단계 +상태: 완료 / 선행: 2·3단계 - [한국어 작업 매뉴얼](../../../docs/Tasks.md)과 [영어 작업 매뉴얼](../../../docs/Tasks.en.md)에 API 옵션·사용 예·기본값·시도 횟수 의미를 함께 반영합니다. - 호출 재시도와 소비자 메시지 재전달을 구분하고, `RemoteError` 포함 재시도가 중복 요청·작업 실행을 일으킬 수 있음을 설명합니다. -- 구현이 완료된 뒤에만 실제 공개 동작을 기준으로 이슈별 `CHANGELOG.md`를 작성합니다. 분석·계획 단계에서는 배포된 기능처럼 변경 로그에 기록하지 않습니다. +- 구현 결과를 기준으로 이슈별 [변경 로그](./CHANGELOG.md)를 작성했습니다. 대응: R-09, AC-09 ### 5. 검증 및 완료 확인 -상태: 대기 / 선행: 2~4단계 +상태: 완료 / 선행: 2~4단계 -- 저장소의 focused unittest 명령으로 메시지 API 회귀 테스트를 실행합니다. -- 변경된 문서와 인수 기준의 일치, 한국어·영어 매뉴얼 간 내용 일치, 새 예제의 실제 공개 API 적합성을 확인합니다. -- 검증하지 않은 실제 브로커 장애·전송 후 중복 처리 동작은 테스트 결과로 주장하지 않고 제한사항으로 남깁니다. -- 실제 수행 결과를 작업 기록 및 `TASKS.md`에 반영하고, 구현이 발생하면 `IMPLEMENTS.md`와 구현 단계별 기록도 별도로 작성합니다. +- 저장소의 focused unittest 명령으로 메시지 API 회귀 테스트를 실행했습니다. +- 변경된 문서와 인수 기준의 일치, 한국어·영어 매뉴얼 간 내용 일치, 새 예제의 실제 공개 API 적합성을 확인했습니다. +- 실제 브로커 장애·전송 후 중복 처리 동작은 검증하지 않았으며, 테스트 결과로 주장하지 않고 제한사항으로 남겼습니다. +- 실제 수행 결과를 작업 기록 및 `TASKS.md`에 반영하고, 구현 종합과 단계별 기록을 작성했습니다. ## 검증 계획 -| 범위 | 방법 | -| --- | --- | -| Worker 메시지 API 회귀 | 저장소 루트에서 `python -m unittest tests.test_task_messages -v` 실행 | -| 재시도 횟수·대기 | Mock 브로커와 Mock 대기 함수로 호출 수·대기 인자·대기 횟수 확인 | -| 기존 전달 계약 | Mock 호출 인자로 본문·작업 설정·priority·timeout 확인 | -| 문서 계약 | `Tasks.md`·`Tasks.en.md`의 API 설명·예·중복 처리 주의사항을 요구사항과 대조 | +| 범위 | 방법 | 결과 | +| --- | --- | --- | +| Worker 메시지 API 회귀 | `tests.test_task_messages` 단위 테스트 | 13개 테스트 통과 | +| 재시도 횟수·대기 | Mock 브로커와 Mock 대기 함수로 호출 수·대기 인자·횟수 확인 | 기본·0회, 최대 3회 추가 재시도, 중간 성공 및 대기 횟수 검증 통과 | +| 기존 전달 계약 | Mock 호출 인자로 본문·작업 설정·priority·timeout 확인 | `emit()`·`request()` 재시도 호출에서 전달 유지 확인 | +| 공개 시그니처·정적 검사 | Pylance 호출 호환성·진단·syntax 검사 | 기존 호출부 호환, 진단·syntax 오류 없음 | +| 문서 계약 | `Tasks.md`·`Tasks.en.md`의 API 설명·예·중복 처리 주의사항 대조 | 한영 매뉴얼 및 이슈별 변경 로그 갱신 | ## 계획의 한계 - 모든 브로커 호출 예외를 재시도하므로 일시적인 전송 오류뿐 아니라 `RemoteError`와 같은 영구적 원격 작업 오류도 반복할 수 있습니다. 이 범위는 사용자 결정이며, 중복 방지는 별도 범위입니다. - 실제 브로커 통합 검증은 단위 테스트와 별개입니다. 구현 시 로컬 브로커 환경이 준비되지 않았다면 실행하지 않은 사실을 기록합니다. -- 현재 이 계획의 테스트·문서 갱신·구현은 실행되지 않았습니다. +- 전체 검증 결과와 실행 환경 제약은 [작업 종합](./TASKS.md)을 기준으로 갱신합니다. diff --git a/issues/feature/55/README.md b/issues/feature/55/README.md index 2a5ed8e..72b58b1 100644 --- a/issues/feature/55/README.md +++ b/issues/feature/55/README.md @@ -9,15 +9,18 @@ - 작업 대상: `yong5eon/DefStream`, 작업 브랜치 `feature/55` - 분기 기준: 깨끗한 로컬 `master`의 `3ce6044` - 병합 대상: 미지정 -- 로컬 진행 상태: 이슈 분석·요구사항·검증 계획 작성 완료, 구현 미착수 +- 로컬 진행 상태: 구현·focused 테스트·한영 매뉴얼 갱신 완료, 실제 브로커 통합 검증 미실행 - 댓글: 없음 ## 작업 문서 - [요구사항 명세](./REQUIREMENTS.md) - [작업 계획](./PLAN.md) +- [구현 종합](./IMPLEMENTS.md) - [작업 종합](./TASKS.md) -- [최신 작업 기록](./tasks/1793705387888.md) +- [변경 로그](./CHANGELOG.md) +- [최신 구현 기록](./implements/1793714914193.md) +- [최신 작업 기록](./tasks/1793715537355.md) ## 이슈 원문 diff --git a/issues/feature/55/REQUIREMENTS.md b/issues/feature/55/REQUIREMENTS.md index 758ee2d..bf94349 100644 --- a/issues/feature/55/REQUIREMENTS.md +++ b/issues/feature/55/REQUIREMENTS.md @@ -28,7 +28,7 @@ Worker의 `emit()`과 `request()`에 선택적 재시도를 추가하여 브로 | ID | 요구사항 | | --- | --- | -| R-01 | `emit()`과 `request()`에 최대 재시도 횟수와 재시도 간격을 선택 인자로 제공해야 합니다. 기본값은 재시도 횟수 `None`, 간격 1초입니다. 공개 인자명은 구현 계획에서 기존 명명 관례에 맞춰 확정합니다. | +| R-01 | `emit()`과 `request()`에 `retry`와 `interval` 키워드 인자를 제공해야 합니다. 기본값은 각각 `None`과 1초입니다. | | R-02 | `None` 또는 `0`회 재시도는 최초 호출만 수행합니다. `N`회 재시도는 최초 호출 후 최대 `N`회 추가 호출까지 허용합니다. | | R-03 | 최초 호출은 대기 없이 수행합니다. 실패 후 재시도가 남은 경우에만 지정 간격을 기다립니다. 성공하면 즉시 반환하고, 마지막 실패 후에는 대기하지 않습니다. | | R-04 | 브로커의 `emit()` 또는 `request()` 호출이 예외를 던지면 재시도 정책을 적용합니다. 사용자가 확정한 범위에는 `RemoteError`를 포함한 브로커 호출의 모든 일반 예외가 포함됩니다. 입력·작업 검증 및 기능 지원 확인 단계에서 발생하는 오류는 재시도하지 않습니다. | @@ -38,15 +38,15 @@ Worker의 `emit()`과 `request()`에 선택적 재시도를 추가하여 브로 | R-08 | 기존 동작을 유지하면서 재시도가 기본 비활성화된 선택 기능으로 동작해야 합니다. 입력 검증·브로커 소유권·연결 수명주기 계약은 바꾸지 않습니다. | | R-09 | 문서에서 `emit()`/`request()` 호출 재시도와 소비 중 메시지 오류·재전달 정책을 구분하고, 이미 처리된 발행·요청이 다시 전송될 수 있음을 안내합니다. | -## 구현 전 구체화할 사항 +## 구체화한 구현 계약 -- 공개 인자명은 `max_retries`와 `retry_interval` 조합을 우선 검토합니다. 기존 `Worker`의 `recovery_attempts`·`recovery_interval`과 이름을 맞추는 대안도 있으므로, 실제 시그니처를 정할 때 일관성을 최종 확인합니다. -- 저장소의 기존 설정 검증은 횟수에서 `bool`과 음수를 거부하고, 간격에서 `bool`·비유한 값·음수를 거부합니다. 새 인자도 같은 계약을 적용할지 구현 전에 확인하고 테스트로 고정해야 합니다. 이슈 원문은 잘못된 값의 오류 타입을 별도로 정하지 않았습니다. +- `retry`는 `None` 또는 `bool`이 아닌 0 이상의 정수입니다. 다른 타입은 `TypeError`, 음수는 `ValueError`로 거부합니다. +- `interval`은 `bool`이 아닌 유한한 0 이상의 정수 또는 실수입니다. 타입이 맞지 않으면 `TypeError`, 음수나 비유한 값은 `ValueError`로 거부합니다. 두 인자의 유효성은 브로커 메서드 호출 전에 검사합니다. - `RemoteError`까지 재시도하면 원격 작업 오류 응답을 받은 경우에도 해당 요청을 다시 실행할 수 있습니다. 이 동작은 사용자가 선택한 예외 범위에 따른 의도된 결과이며, 멱등성이나 중복 방지는 이 요구사항에 포함되지 않습니다. ## 인수 기준 -아래 기준은 구현 후 확인할 목표이며, 현재 통과했다는 의미가 아닙니다. +아래 기준은 구현 완료 후 확인했으며, 검증 범위와 미실행 항목은 [작업 종합](./TASKS.md)에 기록합니다. | ID | 검증 기준 | | --- | --- | diff --git a/issues/feature/55/TASKS.md b/issues/feature/55/TASKS.md index f26488d..ff84060 100644 --- a/issues/feature/55/TASKS.md +++ b/issues/feature/55/TASKS.md @@ -2,16 +2,19 @@ ## 결과 -이슈 #55의 요구를 현재 Worker API, Broker Protocol, 메시지 테스트와 작업 매뉴얼에 대조해 분석했다. `emit()`·`request()` 호출에만 재시도를 적용하고 입력 검증·지원 기능 확인은 재시도 바깥에 두는 방향을 정리했다. +이슈 #55의 요구를 현재 Worker API, Broker Protocol, 메시지 테스트와 작업 매뉴얼에 대조해 분석했다. `emit()`·`request()` 호출에만 재시도를 적용하고 입력 검증·지원 기능 확인은 재시도 바깥에 두었다. 구현 내용과 변경 파일은 [구현 종합](./IMPLEMENTS.md)에 정리했다. 사용자 선택에 따라 브로커 호출에서 발생하는 모든 예외를 재시도하며 `RemoteError`도 포함한다. 따라서 재시도 도중 동일 작업이나 요청이 반복될 수 있다는 제한과 문서 반영 필요성을 명시했다. ## 단계별 기록 - [이슈 #55 분석 및 검증 계획 작성](./tasks/1793705387888.md) +- [Worker 재시도 구현 검증 및 문서 갱신](./tasks/1793714914194.md) +- [재시도 API 인자 이름 변경](./tasks/1793715537355.md) -## 미완료 사항 +## 검증 결과와 제한사항 -- 재시도 공개 인자명과 잘못된 횟수·간격 입력의 오류 계약은 구현 전에 확정해야 한다. -- 계획된 구현, focused unittest, 한국어·영어 매뉴얼 갱신 및 최종 검증은 아직 수행하지 않았다. -- 고객용 변경 로그는 기능 구현이 완료된 뒤 실제 동작을 기준으로 작성한다. +- `tests.test_task_messages` focused suite의 13개 테스트가 통과했다. 기본 Python 명령은 editable `.pth`의 Windows symlink 권한 문제로 import에 실패했으나, workspace Python과 임시 unittest runner로 동일한 suite를 통과시켰다. +- Pylance 함수 시그니처 호환성 검사를 통과했고, `Message.py`에 진단이 없으며 변경 Python 파일의 syntax 오류가 없다. +- 한국어·영어 작업 매뉴얼과 고객용 변경 로그를 갱신했다. +- 실제 RabbitMQ·Redis 브로커 장애·전송 결과의 통합 검증은 수행하지 않았다. `RemoteError`도 재시도하므로 원격 작업이 이미 처리된 경우 중복 실행 가능성이 남는다. diff --git a/issues/feature/55/implements/1793714914193.md b/issues/feature/55/implements/1793714914193.md new file mode 100644 index 0000000..06c91fc --- /dev/null +++ b/issues/feature/55/implements/1793714914193.md @@ -0,0 +1,7 @@ +# Worker 메시지 발행·요청 재시도 구현 + +[Worker 메시지 함수](../../../../src/Worker/Message.py)의 `emit()`·`request()`에 키워드 인자 `retry`와 `interval`을 추가했다. 기본값은 각각 `None`과 1초이며, 재시도 횟수는 최초 호출 이후의 추가 시도 수로 해석한다. 횟수는 `None` 또는 bool이 아닌 0 이상의 정수, 간격은 bool이 아닌 유한한 0 이상의 숫자만 허용하고 브로커 메서드 호출 전에 검증한다. + +공통 내부 호출 경로는 브로커의 실제 `emit()` 또는 `request()` 예외만 재시도한다. 입력·작업·지원 기능 검증은 반복하지 않는다. 다음 시도가 남은 실패에만 `sleep()`을 적용하고, 성공값과 최종 예외 객체를 그대로 반환·전파한다. `request()`의 `timeout` 및 우선순위 인자는 각 시도에 유지된다. 사용자 결정대로 `RemoteError`를 포함한 일반 예외를 모두 재시도하므로 요청·작업이 중복될 수 있다. + +[메시지 API 테스트](../../../../tests/test_task_messages.py)에 Mock 브로커와 Mock 대기를 사용하는 검증을 추가했다. `emit()`과 `request()`의 13개 focused 테스트가 통과했으며, 실제 브로커 연결 검증은 수행하지 않았다. API 사용법과 중복 실행 주의사항은 한국어·영어 [작업 매뉴얼](../../../../docs/Tasks.md), [영문 작업 매뉴얼](../../../../docs/Tasks.en.md)과 [변경 로그](../CHANGELOG.md)에 반영했다. diff --git a/issues/feature/55/tasks/1793714914194.md b/issues/feature/55/tasks/1793714914194.md new file mode 100644 index 0000000..398d0fe --- /dev/null +++ b/issues/feature/55/tasks/1793714914194.md @@ -0,0 +1,9 @@ +# Worker 재시도 구현 검증 및 문서 갱신 + +저장소의 `tests.test_task_messages` focused suite를 실행해 13개 테스트가 통과함을 확인했다. 기본 `python -m unittest tests.test_task_messages -v` 실행은 전역 Python의 editable `.pth`가 Windows symlink 권한 문제로 패키지 경로를 구성하지 못해 import 단계에서 실패했다. workspace 가상 환경의 Python에서 임시 unittest runner를 사용하고 `DefStream.__path__`에 `src`를 앞에 추가해 같은 테스트를 실행했으며 통과했다. + +Pylance `checkSignatureCompatibility`에서 `emit()`과 `request()`의 기존 호출부가 각각 호환됨을 확인했다. `Message.py`의 Pylance diagnostics는 비어 있고, 변경된 Python 소스와 테스트 모두 syntax 오류가 없다. + +한국어·영어 `Tasks` 매뉴얼에 공개 인자, 기본값, 추가 시도 횟수, timeout 전달 및 `RemoteError`로 인한 중복 실행 가능성을 추가했다. 실제 RabbitMQ·Redis 통합 장애 테스트는 수행하지 않았다. + +상세 작업 매뉴얼에 API와 사용 주의사항을 반영했으므로 프로젝트 소개와 빠른 시작을 담당하는 루트 README는 변경하지 않았다. diff --git a/issues/feature/55/tasks/1793715537355.md b/issues/feature/55/tasks/1793715537355.md new file mode 100644 index 0000000..b9aee50 --- /dev/null +++ b/issues/feature/55/tasks/1793715537355.md @@ -0,0 +1,5 @@ +# 재시도 API 인자 이름 변경 + +사용자 요청에 따라 공개 API 인자명을 `max_retries`에서 `retry`로, `retry_interval`에서 `interval`로 변경했다. `emit()`·`request()` 구현, 단위 테스트, 한국어·영어 작업 매뉴얼, 요구사항·계획·구현 요약과 고객용 변경 로그를 새 이름에 맞췄다. 재시도 횟수와 기본값·입력 검증 동작은 변경하지 않았다. + +`tests.test_task_messages` focused suite를 다시 실행해 13개 테스트가 통과함을 확인했다. Pylance에서 두 공개 함수의 호출부 호환성을 확인했고 `Message.py` 진단은 없으며 syntax 검사도 통과했다. 저장소에서 기존 인자명 문자열을 검색했을 때 일치 항목은 없었다. diff --git a/src/Worker/Message.py b/src/Worker/Message.py index a5f5b01..4167d9a 100644 --- a/src/Worker/Message.py +++ b/src/Worker/Message.py @@ -1,9 +1,13 @@ from collections.abc import Mapping from inspect import isabstract +from math import isfinite +from time import sleep from typing import ( Any, + Callable, Optional, Type, + Union, ) from ..Broker.Connection import ( @@ -78,6 +82,53 @@ def priority_parameters( ) +def _retry_settings( + retry: Optional[int], + interval: Union[int, float], +) -> int: + if retry is not None and ( + isinstance( + retry, + bool, + ) or not isinstance( + retry, + int, + ) + ): + raise TypeError('Retry count must be an integer or None') + if retry is not None and retry < 0: + raise ValueError('Retry count must not be negative') + if isinstance( + interval, + bool, + ) or not isinstance( + interval, + ( + int, + float, + ), + ): + raise TypeError('Interval must be a number of seconds') + if not isfinite(interval) or interval < 0: + raise ValueError('Interval must be finite and non-negative') + return 0 if retry is None else retry + + +def _retry_call( + call: Callable[[], Any], + retry: int, + interval: Union[int, float], +) -> Any: + while True: + try: + return call() + except Exception: + if retry == 0: + raise + retry -= 1 + sleep(interval) + + def declare( *, task: Type[Run], @@ -103,7 +154,13 @@ def emit( task: Type[Run], broker: Connection, priority: Optional[int] = None, + retry: Optional[int] = None, + interval: Union[int, float] = 1, ) -> Any: + retries = _retry_settings( + retry, + interval, + ) parameters = task_parameters(task) payload = message_body(body) if not isinstance( @@ -126,14 +183,22 @@ def emit( PrioritySupport, ): raise NotImplementedError('This connection does not support task priorities') - return broker.emit( + return _retry_call( + lambda: broker.emit( + payload, + parameters, + priority=effective, + ), + retries, + interval, + ) + return _retry_call( + lambda: broker.emit( payload, parameters, - priority=effective, - ) - return broker.emit( - payload, - parameters, + ), + retries, + interval, ) @@ -144,7 +209,13 @@ def request( broker: Connection, timeout: float = 5, priority: Optional[int] = None, + retry: Optional[int] = None, + interval: Union[int, float] = 1, ) -> Any: + retries = _retry_settings( + retry, + interval, + ) parameters = task_parameters(task) payload = message_body(body) if not isinstance( @@ -167,14 +238,22 @@ def request( PrioritySupport, ): raise NotImplementedError('This connection does not support task priorities') - return broker.request( + return _retry_call( + lambda: broker.request( + payload, + parameters, + timeout=timeout, + priority=effective, + ), + retries, + interval, + ) + return _retry_call( + lambda: broker.request( payload, parameters, timeout=timeout, - priority=effective, - ) - return broker.request( - payload, - parameters, - timeout=timeout, + ), + retries, + interval, ) diff --git a/tests/test_task_messages.py b/tests/test_task_messages.py index 8a1b255..a385539 100644 --- a/tests/test_task_messages.py +++ b/tests/test_task_messages.py @@ -5,9 +5,13 @@ ) from unittest.mock import ( Mock, + call, + patch, ) from DefStream.Broker import ( Connection, + PrioritySupport, + RemoteError, ) from DefStream.Worker import ( declare, @@ -83,11 +87,17 @@ def test_helpers_reject_missing_capabilities_before_broker_io(self): ), ( emit, - {'body': {}}, + { + 'body': {}, + 'retry': 2, + }, ), ( request, - {'body': {}}, + { + 'body': {}, + 'retry': 2, + }, ), ( request, @@ -130,6 +140,213 @@ def test_request_does_not_require_emission_capability(self): ) return + def test_emit_retries_until_success_and_waits_between_attempts(self): + broker = Mock( + spec=PrioritySupport, + emit=Mock(side_effect=[ + RuntimeError('temporary failure'), + RuntimeError('temporary failure'), + 'message-id', + ]), + ) + broker.priority_parameters.return_value = Task.parameters + with patch('DefStream.Worker.Message.sleep') as wait: + self.assertEqual( + emit( + {'a': 1}, + task=Task, + broker=broker, + priority=3, + retry=3, + interval=0.25, + ), + 'message-id', + ) + self.assertEqual( + broker.emit.call_count, + 3, + ) + self.assertEqual( + wait.call_args_list, + [ + call(0.25), + call(0.25), + ], + ) + broker.emit.assert_has_calls([ + call( + {'a': 1}, + Task.parameters, + priority=3, + ), + call( + {'a': 1}, + Task.parameters, + priority=3, + ), + call( + {'a': 1}, + Task.parameters, + priority=3, + ), + ]) + + def test_request_retries_remote_errors_and_preserves_timeout(self): + broker = Mock( + spec=PrioritySupport, + request=Mock(side_effect=[ + RemoteError( + 'TaskError', + 'temporary failure', + ), + 3, + ]), + ) + broker.priority_parameters.return_value = Task.parameters + with patch('DefStream.Worker.Message.sleep') as wait: + self.assertEqual( + request( + {'a': 1}, + task=Task, + broker=broker, + timeout=2, + priority=4, + retry=1, + interval=0.5, + ), + 3, + ) + broker.request.assert_has_calls([ + call( + {'a': 1}, + Task.parameters, + timeout=2, + priority=4, + ), + call( + {'a': 1}, + Task.parameters, + timeout=2, + priority=4, + ), + ]) + wait.assert_called_once_with(0.5) + + def test_no_retry_default_and_zero_call_once(self): + for helper in ( + emit, + request, + ): + for retry in ( + None, + 0, + ): + broker = Mock( + emit=Mock(side_effect=RuntimeError('broker call failed')), + request=Mock(side_effect=RuntimeError('broker call failed')), + ) + with self.subTest(helper=helper, retry=retry), patch( + 'DefStream.Worker.Message.sleep', + ) as wait: + with self.assertRaisesRegex( + RuntimeError, + 'broker call failed', + ): + helper( + {}, + task=Task, + broker=broker, + retry=retry, + ) + if helper is emit: + broker.emit.assert_called_once_with( + {}, + Task.parameters, + ) + broker.request.assert_not_called() + else: + broker.emit.assert_not_called() + broker.request.assert_called_once_with( + {}, + Task.parameters, + timeout=5, + ) + wait.assert_not_called() + + def test_retries_stop_at_limit_and_raise_the_last_exception(self): + errors = [ + RuntimeError('first failure'), + RuntimeError('second failure'), + RuntimeError('third failure'), + RuntimeError('last failure'), + ] + broker = Mock( + emit=Mock(side_effect=errors), + ) + with patch('DefStream.Worker.Message.sleep') as wait: + with self.assertRaises(RuntimeError) as raised: + emit( + {}, + task=Task, + broker=broker, + retry=3, + ) + self.assertIs( + raised.exception, + errors[-1], + ) + self.assertEqual( + broker.emit.call_count, + 4, + ) + self.assertEqual( + wait.call_args_list, + [ + call(1), + call(1), + call(1), + ], + ) + + def test_retry_settings_are_validated_before_broker_calls(self): + broker = Mock( + spec=Connection, + emit=Mock(), + request=Mock(), + ) + for helper in ( + emit, + request, + ): + for value, error in ( + (True, TypeError), + (1.5, TypeError), + (-1, ValueError), + ): + with self.subTest(helper=helper, retry=value), self.assertRaises(error): + helper( + {}, + task=Task, + broker=broker, + retry=value, + ) + for value, error in ( + (True, TypeError), + ('1', TypeError), + (-0.1, ValueError), + (float('inf'), ValueError), + (float('nan'), ValueError), + ): + with self.subTest(helper=helper, interval=value), self.assertRaises(error): + helper( + {}, + task=Task, + broker=broker, + interval=value, + ) + broker.emit.assert_not_called() + broker.request.assert_not_called() + def test_declare_delegates_without_owning_connection(self): broker = Mock() broker.declare = Mock() From e565ed093adeed9fcf8c1957fb262548e685b0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=97=88=EC=9A=A9=EC=84=A0?= Date: Tue, 3 Nov 2026 23:51:11 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=EC=9D=98=20=EB=B2=94=EC=9C=84=20=EC=B4=88=EA=B3=BC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=82=AC=EC=A0=84=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브로커 호출 전 interval 상한 검증과 큰 정수 변환 오버플로 방지 - 경계값 회귀 테스트 및 한영 매뉴얼 보완 - 관련 이슈: #55 https://github.com/yong5eon/DefStream/issues/55 - 관련 리뷰: https://github.com/yong5eon/DefStream/pull/61 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/Tasks.en.md | 3 +- docs/Tasks.md | 3 +- issues/feature/55/CHANGELOG.md | 2 + issues/feature/55/IMPLEMENTS.md | 3 + issues/feature/55/PLAN.md | 2 +- issues/feature/55/README.md | 4 +- issues/feature/55/REQUIREMENTS.md | 2 +- issues/feature/55/TASKS.md | 2 + issues/feature/55/implements/1793717149425.md | 7 ++ issues/feature/55/tasks/1793717149426.md | 9 +++ src/Worker/Message.py | 5 +- tests/test_task_messages.py | 65 +++++++++++++++++++ 12 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 issues/feature/55/implements/1793717149425.md create mode 100644 issues/feature/55/tasks/1793717149426.md diff --git a/docs/Tasks.en.md b/docs/Tasks.en.md index 0fa2089..c859851 100644 --- a/docs/Tasks.en.md +++ b/docs/Tasks.en.md @@ -126,7 +126,8 @@ counts additional attempts after the first call, so `retry=3` allows up to four calls. The default `interval` is one second; it is applied only after a failure when another attempt remains. `retry` must be `None` or a non-negative integer other than `bool`, and `interval` must be a finite non-negative number other -than `bool`. Invalid values are rejected before the broker call. +than `bool`. To stay within the platform's supported wait range, `interval` is capped +at `threading.TIMEOUT_MAX` seconds. Out-of-range values raise `ValueError` before the broker call. ```python emit( diff --git a/docs/Tasks.md b/docs/Tasks.md index 7b6642d..7f6ed04 100644 --- a/docs/Tasks.md +++ b/docs/Tasks.md @@ -123,7 +123,8 @@ result = request( 추가 횟수이므로 `retry=3`은 최대 네 번 호출합니다. 실패 후 다음 시도가 남았을 때만 `interval`초를 기다리며 기본 간격은 1초입니다. `retry`는 `None` 또는 bool이 아닌 0 이상의 정수이고, `interval`은 bool이 아닌 -유한한 0 이상의 숫자여야 합니다. 잘못된 값은 브로커 호출 전에 거부합니다. +유한한 0 이상의 숫자여야 합니다. 플랫폼의 대기 시간 범위를 넘지 않도록 `interval`의 상한은 +`threading.TIMEOUT_MAX`초로 제한합니다. 범위를 벗어난 값은 브로커 호출 전에 `ValueError`로 거부합니다. ```python emit( diff --git a/issues/feature/55/CHANGELOG.md b/issues/feature/55/CHANGELOG.md index 43299db..9239b1d 100644 --- a/issues/feature/55/CHANGELOG.md +++ b/issues/feature/55/CHANGELOG.md @@ -3,3 +3,5 @@ `emit()`과 `request()`에 `retry`와 `interval` 옵션을 추가했습니다. 기본 설정은 기존처럼 한 번만 호출하며, 재시도 횟수를 설정하면 실패한 브로커 호출을 지정 횟수까지 다시 시도합니다. `request()`의 `timeout`은 각 호출에 적용됩니다. 브로커 호출에서 발생한 `RemoteError`를 포함한 예외도 재시도 대상입니다. 원격 작업이 이미 실행된 뒤 오류 응답을 받은 요청도 다시 실행될 수 있으므로, 중복 요청이나 부작용에 안전한 작업에만 재시도를 설정하세요. + +`interval`은 0 이상 `threading.TIMEOUT_MAX`초 이하로 제한합니다. 지나치게 큰 간격은 브로커 호출 전에 `ValueError`로 거부하여 대기 중 오버플로가 브로커 오류를 가리는 상황을 방지합니다. diff --git a/issues/feature/55/IMPLEMENTS.md b/issues/feature/55/IMPLEMENTS.md index ba1efeb..a4b8a0f 100644 --- a/issues/feature/55/IMPLEMENTS.md +++ b/issues/feature/55/IMPLEMENTS.md @@ -8,6 +8,8 @@ 브로커 인터페이스나 구현체, 연결 생성·종료 동작은 변경하지 않았습니다. +PR 리뷰 후 `interval`을 `threading.TIMEOUT_MAX` 이하로 제한하고 범위 비교를 실수 변환보다 먼저 수행하도록 보완했습니다. 지나치게 큰 간격은 브로커 호출 전에 `ValueError`로 거부합니다. 경계값 테스트 2개를 추가하여 현재 메시지 API 테스트 15개가 통과했습니다. + 초기 구현 후 공개 인자명을 `retry`와 `interval`로 변경했습니다. 현재 API와 문서에는 이전 이름을 남기지 않았습니다. ## 검증 @@ -21,3 +23,4 @@ ## 단계별 기록 - [Worker 재시도 구현](./implements/1793714914193.md) +- [재시도 간격 오버플로 사전 차단](./implements/1793717149425.md) diff --git a/issues/feature/55/PLAN.md b/issues/feature/55/PLAN.md index f8ead32..ce1469f 100644 --- a/issues/feature/55/PLAN.md +++ b/issues/feature/55/PLAN.md @@ -64,7 +64,7 @@ | 범위 | 방법 | 결과 | | --- | --- | --- | -| Worker 메시지 API 회귀 | `tests.test_task_messages` 단위 테스트 | 13개 테스트 통과 | +| Worker 메시지 API 회귀 | `tests.test_task_messages` 단위 테스트 | PR 리뷰 경계값 보완 후 15개 테스트 통과 | | 재시도 횟수·대기 | Mock 브로커와 Mock 대기 함수로 호출 수·대기 인자·횟수 확인 | 기본·0회, 최대 3회 추가 재시도, 중간 성공 및 대기 횟수 검증 통과 | | 기존 전달 계약 | Mock 호출 인자로 본문·작업 설정·priority·timeout 확인 | `emit()`·`request()` 재시도 호출에서 전달 유지 확인 | | 공개 시그니처·정적 검사 | Pylance 호출 호환성·진단·syntax 검사 | 기존 호출부 호환, 진단·syntax 오류 없음 | diff --git a/issues/feature/55/README.md b/issues/feature/55/README.md index 72b58b1..b6852df 100644 --- a/issues/feature/55/README.md +++ b/issues/feature/55/README.md @@ -19,8 +19,8 @@ - [구현 종합](./IMPLEMENTS.md) - [작업 종합](./TASKS.md) - [변경 로그](./CHANGELOG.md) -- [최신 구현 기록](./implements/1793714914193.md) -- [최신 작업 기록](./tasks/1793715537355.md) +- [최신 구현 기록](./implements/1793717149425.md) +- [최신 작업 기록](./tasks/1793717149426.md) ## 이슈 원문 diff --git a/issues/feature/55/REQUIREMENTS.md b/issues/feature/55/REQUIREMENTS.md index bf94349..029bb7d 100644 --- a/issues/feature/55/REQUIREMENTS.md +++ b/issues/feature/55/REQUIREMENTS.md @@ -41,7 +41,7 @@ Worker의 `emit()`과 `request()`에 선택적 재시도를 추가하여 브로 ## 구체화한 구현 계약 - `retry`는 `None` 또는 `bool`이 아닌 0 이상의 정수입니다. 다른 타입은 `TypeError`, 음수는 `ValueError`로 거부합니다. -- `interval`은 `bool`이 아닌 유한한 0 이상의 정수 또는 실수입니다. 타입이 맞지 않으면 `TypeError`, 음수나 비유한 값은 `ValueError`로 거부합니다. 두 인자의 유효성은 브로커 메서드 호출 전에 검사합니다. +- `interval`은 `bool`이 아닌 유한한 0 이상 `threading.TIMEOUT_MAX` 이하의 정수 또는 실수입니다. 타입이 맞지 않으면 `TypeError`, 범위 밖 값이나 비유한 값은 `ValueError`로 거부합니다. 큰 정수의 실수 변환 오버플로를 피하도록 범위를 먼저 확인하며, 두 인자의 유효성은 브로커 메서드 호출 전에 검사합니다. - `RemoteError`까지 재시도하면 원격 작업 오류 응답을 받은 경우에도 해당 요청을 다시 실행할 수 있습니다. 이 동작은 사용자가 선택한 예외 범위에 따른 의도된 결과이며, 멱등성이나 중복 방지는 이 요구사항에 포함되지 않습니다. ## 인수 기준 diff --git a/issues/feature/55/TASKS.md b/issues/feature/55/TASKS.md index ff84060..ec4a2f3 100644 --- a/issues/feature/55/TASKS.md +++ b/issues/feature/55/TASKS.md @@ -11,9 +11,11 @@ - [이슈 #55 분석 및 검증 계획 작성](./tasks/1793705387888.md) - [Worker 재시도 구현 검증 및 문서 갱신](./tasks/1793714914194.md) - [재시도 API 인자 이름 변경](./tasks/1793715537355.md) +- [PR 리뷰 경계값 회귀 검증](./tasks/1793717149426.md) ## 검증 결과와 제한사항 +- PR 리뷰 보완 후 15개 테스트가 통과했다. 큰 간격의 사전 거부와 허용 경계를 검증했으며, 수정 전 회귀 테스트 실패도 확인했다. - `tests.test_task_messages` focused suite의 13개 테스트가 통과했다. 기본 Python 명령은 editable `.pth`의 Windows symlink 권한 문제로 import에 실패했으나, workspace Python과 임시 unittest runner로 동일한 suite를 통과시켰다. - Pylance 함수 시그니처 호환성 검사를 통과했고, `Message.py`에 진단이 없으며 변경 Python 파일의 syntax 오류가 없다. - 한국어·영어 작업 매뉴얼과 고객용 변경 로그를 갱신했다. diff --git a/issues/feature/55/implements/1793717149425.md b/issues/feature/55/implements/1793717149425.md new file mode 100644 index 0000000..505281c --- /dev/null +++ b/issues/feature/55/implements/1793717149425.md @@ -0,0 +1,7 @@ +# 재시도 간격 오버플로 사전 차단 + +[PR #61 리뷰](https://github.com/yong5eon/DefStream/pull/61#discussion_r4456061735)와 [대기 오버플로 지적](https://github.com/yong5eon/DefStream/pull/61#discussion_r4456061789)에 따라 [Message.py](../../../../src/Worker/Message.py)의 간격 검증만 보완했다. + +`interval`은 플랫폼별 `threading.TIMEOUT_MAX`초 이하로 제한한다. `isfinite()`보다 먼저 범위를 비교하여 `10**400` 같은 정수의 실수 변환 오버플로도 피한다. 범위 밖 값은 브로커 호출 전에 `ValueError`로 거부하며, 재시도 루프·공개 시그니처·기본값은 유지한다. + +한국어·영어 매뉴얼과 요구사항·변경 로그에 상한을 반영했다. 이슈 원문과 기존 단계별 기록은 변경하지 않았다. 검증 결과는 [작업 기록](../tasks/1793717149426.md)에 정리했다. diff --git a/issues/feature/55/tasks/1793717149426.md b/issues/feature/55/tasks/1793717149426.md new file mode 100644 index 0000000..d4c74fb --- /dev/null +++ b/issues/feature/55/tasks/1793717149426.md @@ -0,0 +1,9 @@ +# PR 리뷰 경계값 회귀 검증 + +수정 전 `tests.test_task_messages.MessageTests.test_interval_above_timeout_max_is_rejected_before_broker_calls`를 실행하여 두 API에서 `10**400`은 `OverflowError`, `1e100`과 `TIMEOUT_MAX + 1`은 사전 거부 누락으로 실패하는 것을 확인했다. + +수정 후 workspace Python에서 `DefStream.__path__`의 첫 경로를 `src`로 지정한 기존 unittest 실행 방식으로 `tests.test_task_messages`를 실행했다. 15개 테스트가 통과했다. 두 API에서 범위 밖 값은 브로커 호출·대기 없이 거부되고, 0과 `TIMEOUT_MAX` 경계는 허용되어 Mock 대기에 그대로 전달됨을 확인했다. 실제 장시간 대기는 수행하지 않았다. + +Pylance의 `Message.py` 진단은 없고, 변경 테스트의 syntax 검사도 통과했다. 실제 브로커 통합 테스트는 실행하지 않았다. + +[앞선 인자명 변경 기록](./1793715537355.md)의 “기존 인자명 검색 결과 없음”은 당시 코드·현행 매뉴얼 대상 결과로 한정해 읽어야 한다. 변경 전 이름을 보존한 기록 자체까지 포함한 저장소 전체 결과를 뜻하지 않는다. diff --git a/src/Worker/Message.py b/src/Worker/Message.py index 4167d9a..9a40a31 100644 --- a/src/Worker/Message.py +++ b/src/Worker/Message.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from inspect import isabstract from math import isfinite +from threading import TIMEOUT_MAX from time import sleep from typing import ( Any, @@ -109,8 +110,8 @@ def _retry_settings( ), ): raise TypeError('Interval must be a number of seconds') - if not isfinite(interval) or interval < 0: - raise ValueError('Interval must be finite and non-negative') + if interval < 0 or interval > TIMEOUT_MAX or not isfinite(interval): + raise ValueError('Interval must be finite and between 0 and threading.TIMEOUT_MAX') return 0 if retry is None else retry diff --git a/tests/test_task_messages.py b/tests/test_task_messages.py index a385539..04c0d53 100644 --- a/tests/test_task_messages.py +++ b/tests/test_task_messages.py @@ -1,3 +1,4 @@ +from threading import TIMEOUT_MAX from typing import get_type_hints from unittest import ( TestCase, @@ -347,6 +348,70 @@ def test_retry_settings_are_validated_before_broker_calls(self): broker.emit.assert_not_called() broker.request.assert_not_called() + def test_interval_above_timeout_max_is_rejected_before_broker_calls(self): + for helper in ( + emit, + request, + ): + for interval in ( + 10**400, + 1e100, + TIMEOUT_MAX + 1, + ): + broker = Mock( + spec=Connection, + emit=Mock(), + request=Mock(), + ) + with self.subTest(helper=helper, interval=interval), patch( + 'DefStream.Worker.Message.sleep', + ) as wait: + with self.assertRaises(ValueError): + helper( + {}, + task=Task, + broker=broker, + retry=1, + interval=interval, + ) + broker.emit.assert_not_called() + broker.request.assert_not_called() + wait.assert_not_called() + + def test_interval_boundaries_are_passed_to_retry_wait(self): + for helper in ( + emit, + request, + ): + for interval in ( + 0, + TIMEOUT_MAX, + ): + result = object() + operation = Mock(side_effect=[ + RuntimeError('broker call failed'), + result, + ]) + broker = Mock( + spec=Connection, + **{helper.__name__: operation}, + ) + with self.subTest(helper=helper, interval=interval), patch( + 'DefStream.Worker.Message.sleep', + ) as wait: + self.assertIs( + helper( + {}, + task=Task, + broker=broker, + retry=1, + interval=interval, + ), + result, + ) + self.assertEqual(operation.call_count, 2) + wait.assert_called_once_with(interval) + def test_declare_delegates_without_owning_connection(self): broker = Mock() broker.declare = Mock() From 4ec8270e5b263513078df5affe0de42875a56e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=97=88=EC=9A=A9=EC=84=A0?= Date: Wed, 4 Nov 2026 00:01:50 +0900 Subject: [PATCH 5/5] Update regression test details and documentation Updated regression test results and verified compatibility of function signatures. Revised documentation in Korean and English, including the changelog. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- issues/feature/55/IMPLEMENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/feature/55/IMPLEMENTS.md b/issues/feature/55/IMPLEMENTS.md index a4b8a0f..e5472e1 100644 --- a/issues/feature/55/IMPLEMENTS.md +++ b/issues/feature/55/IMPLEMENTS.md @@ -14,7 +14,7 @@ PR 리뷰 후 `interval`을 `threading.TIMEOUT_MAX` 이하로 제한하고 범 ## 검증 -- [메시지 API 회귀 테스트](../../../tests/test_task_messages.py): 13개 테스트 통과. 재시도 횟수·대기, 기본값·0회, 중간 성공, 최종 예외 객체 보존, `RemoteError`, priority·timeout, 입력 검증을 확인했습니다. +- [메시지 API 회귀 테스트](../../../tests/test_task_messages.py): 15개 테스트 통과. 재시도 횟수·대기, 기본값·0회, 중간 성공, 최종 예외 객체 보존, `RemoteError`, priority·timeout, 입력 검증과 간격 경계값을 확인했습니다. - Pylance 함수 시그니처 호환성 검사에서 `emit()`·`request()`의 기존 호출부가 각각 호환됨을 확인했습니다. - Pylance 진단에서 오류가 없고, 변경한 Python 소스와 테스트의 syntax 검사도 통과했습니다. - 한국어·영어 작업 매뉴얼과 고객용 [변경 로그](./CHANGELOG.md)를 갱신했습니다.