From 8f568141862815192d91687f9913323d7076b3e2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 03:47:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(retry):=20review=20round=201=20=E2=80=94=20?= =?UTF-8?q?minor/nit=204=EA=B1=B4=20=EC=9D=BC=EA=B4=84=20(#2=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m1 — NoteRepository.test.ts 에 retryAllFailed OR IGNORE race-safe 회귀 가드 1 case 추가. failed 노트인데 pending_jobs row 가 이미 존재하는 비정상 race 상태 시뮬레이션 → INSERT OR IGNORE 라 duplicate 안 됨, 기존 attempts/next_run_at 보존. m2 — store.retryAllFailed 의 r.count 무시 의도 주석 1줄. 단일 process (Electron) 환경 + 모든 ai_status='failed' 가 retry 대상이라 사용자 시점 카운트는 0 reset 가 정확. n1 — AiWorker unreachableBackoffStep increment 명료화. Math.min(..., length-1) → 명시적 if 가드 (step < length-1) 로 cap 도달 시 no-op 의도 가시화. 동작 동일. n2 — AiWorker.processJob 의 max 의미 주석 1줄. unreachable/timeout 분기는 attempt -= 1 로 인덱스 stay 라 max 무관 — future maintainer 위해 명시. n3 (FailedBanner inline style) 은 v0.2.4 backlog (banner theme cleanup). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 8 +++++++- src/renderer/inbox/store.ts | 2 ++ tests/unit/NoteRepository.test.ts | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index 6f8fc5d..b1de1c6 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -120,6 +120,8 @@ export class AiWorker { } private async processJob(job: Job): Promise { + // `max` 는 schema/other 분기 (attempts 증가) 의 cap 이다. + // unreachable/timeout 분기는 `attempt -= 1; continue` 로 인덱스 stay — max 와 무관 무한 retry. const max = this.backoffsMs.length; for (let attempt = job.attempts; attempt < max; attempt++) { const startMs = this.now().getTime(); @@ -170,7 +172,11 @@ export class AiWorker { // 무한 retry: attempts 증가 안 함, in-place loop + sleep. // markAiFailed / ai_failed emit 안 함 — ratio 통계는 schema/other 만 누적. const sleepMs = this.nextBackoffMs(this.unreachableBackoffStep); - this.unreachableBackoffStep = Math.min(this.unreachableBackoffStep + 1, this.unreachableBackoffsMs.length - 1); + // step 이 cap 도달 후엔 인덱스 stay — increment 는 무의미하지만 안전한 no-op. + // (Math.min 가드: cap 넘어가도 length-1 로 묶임.) + if (this.unreachableBackoffStep < this.unreachableBackoffsMs.length - 1) { + this.unreachableBackoffStep += 1; + } const nextRunAt = new Date(Date.now() + sleepMs).toISOString(); this.repo.setNextRunAt(job.noteId, nextRunAt, msg); await this.sleep(sleepMs); diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 33f37a7..92ed25c 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -182,6 +182,8 @@ export const useInbox = create((set, get) => ({ await inboxApi.retryAllFailed(); // 낙관적 갱신: failedCount = 0. AiWorker 처리 진행 중에 PendingBanner 가 N건 노출. // refreshMeta 가 트리거되면 자연 동기 (worker.onUpdate → main → renderer). + // 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관, + // 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확. set({ failedCount: 0 }); } })); diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 771299c..93e5e8c 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -628,6 +628,21 @@ describe('NoteRepository — failed retry helpers', () => { expect(repo.retryAllFailed('2026-05-01T12:00:00.000Z')).toEqual({ ids: [] }); }); + it('retryAllFailed — pending_jobs 이미 존재 시 OR IGNORE (race 안전)', () => { + // failed 노트인데 pending_jobs row 가 이미 존재하는 비정상 race 상태 시뮬레이션. + // attempts=2, next_run_at=과거 — retryAllFailed 가 INSERT OR IGNORE 라 그대로 보존되어야. + const id = makeFailed('a'); + db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 2, ?)`) + .run(id, '2026-04-30T00:00:00.000Z'); + const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z'); + expect(r.ids).toEqual([id]); + const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id); + expect(jobs).toHaveLength(1); // duplicate 안 됨 + // OR IGNORE 라 기존 row 보존 — attempts=2, nextRunAt 그대로 + expect(jobs[0]!.attempts).toBe(2); + expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z'); + }); + it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => { const { id } = repo.create({ rawText: 'x' }); repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error');