Skip to content

[Refactor] LLM 호출 동시성 및 중복 작업 제어 개선 (#113)#114

Merged
shinae1023 merged 4 commits into
devfrom
refactor/#113-llm-concurrency
Jul 4, 2026
Merged

[Refactor] LLM 호출 동시성 및 중복 작업 제어 개선 (#113)#114
shinae1023 merged 4 commits into
devfrom
refactor/#113-llm-concurrency

Conversation

@shinae1023

@shinae1023 shinae1023 commented Jul 4, 2026

Copy link
Copy Markdown
Member

✨ 어떤 이유로 PR를 하셨나요?

  • feature 병합
  • 버그 수정(아래에 issue #를 남겨주세요)
  • 코드 개선
  • 코드 수정
  • 배포
  • 기타(아래에 자세한 내용 기입해주세요)

📋 세부 내용 - 왜 해당 PR이 필요한지 작업 내용을 자세하게 설명해주세요

  • OpenAI 호출 경로에 전역 동시성 제한을 추가해 외부 API rate limit과 과도한 동시 요청을 제어했습니다.
  • 동일 사용자/자소서 분석 비동기 작업의 중복 생성 race를 방지하도록 task 생성 흐름과 DB unique 제약을 보강했습니다.
  • 추천 질문 캐시 miss 상황에서 중복 LLM 호출이 발생하지 않도록 single-flight 처리와 저장 충돌 후 재조회 fallback을 적용했습니다.
  • analysis 비동기 흐름을 prepare / execute / finalize 단계로 분리해 LLM 호출 중 긴 DB 트랜잭션이 유지되지 않도록 리팩터링했습니다.
  • 동기/비동기 분석 경로가 동일한 실행 파이프라인을 사용하도록 정리하고, finalize 단계에서만 최종 분석 저장과 상태 변경을 수행하도록 조정했습니다.
  • 기존 분석 기능 회귀가 없도록 분석 서비스 및 async facade 테스트를 검증했습니다.
  • analysis 비동기 작업의 크레딧 처리를 즉시 차감/환불 방식에서 reserve -> confirm/release 흐름으로 정리했습니다.
  • analysis async task에 크레딧 예약 상태와 reference를 저장해 재시도와 실패 보상 흐름을 더 명확하게 추적할 수 있도록 했습니다.
  • 동기/비동기 분석 경로 모두 동일한 예약-확정 모델을 따르도록 맞추고 관련 테스트를 검증했습니다.

📸 작업 화면 스크린샷

⚠️ PR하기 전에 확인해주세요

  • 로컬테스트를 진행하셨나요?
  • 머지할 브랜치를 확인하셨나요?
  • 관련 label을 선택하셨나요?

🚨 관련 이슈 번호 [ ]

Summary by CodeRabbit

  • New Features

    • Added safeguards to better handle concurrent requests during AI-powered analysis and question generation.
  • Bug Fixes

    • Prevented duplicate active analysis tasks from being created for the same user and item.
    • Improved reliability when multiple requests try to generate the same mock questions at once.
    • Reduced failures when AI services are under heavy load by limiting concurrent requests.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds an LlmConcurrencyLimiter to throttle concurrent OpenAI calls in AnalysisAiClient and JobPostingAiService, introduces a partial unique DB index and race-safe pending-task creation to prevent duplicate analysis tasks, and adds a MockQuestionInflightRegistry to deduplicate concurrent mock question generation with cache-conflict fallback. Configuration and tests are updated accordingly.

Changes

LLM concurrency limiting and duplicate task/cache guards

Layer / File(s) Summary
LlmConcurrencyLimiter core and config
src/main/java/.../global/config/LlmConcurrencyLimiter.java, src/main/resources/application-dev.yaml, src/main/resources/application-prod.yaml, src/test/resources/application-test.yaml
New fair-semaphore-based limiter component with configurable max concurrency and acquire timeout, throwing GeneralException on timeout/interrupt; matching llm.concurrency and async.llm settings added to dev, prod, and test configs.
AI service call-site wiring
AnalysisAiClient.java, JobPostingAiService.java, JobPostingAiServiceTest.java
AnalysisAiClient and JobPostingAiService inject LlmConcurrencyLimiter and route all OpenAI responses().create(...) calls through execute(...) with distinct operation keys; tests updated to mock and inject the new dependency.
Duplicate pending analysis task prevention
ops/db/migrations/20260704_analysis_async_tasks_active_unique.sql, AnalysisAsyncTaskService.java, AnalysisAsyncFacadeService.java, AnalysisAsyncFacadeServiceTest.java
A partial unique index enforces one active (PENDING/RUNNING) task per user/mock-apply pair; createPendingTask now returns the saved entity via saveAndFlush; the facade catches DataIntegrityViolationException, fetches the existing active task, and returns an existing-task response via a new PendingTaskResult record, avoiding duplicate credit charges/processing.
Mock question in-flight deduplication
MockQuestionInflightRegistry.java, MockQuestionCacheService.java, MockQuestionCacheServiceTest.java
New MockQuestionInflightRegistry deduplicates concurrent generation per cache key using FutureTask; MockQuestionCacheService refactors cache lookup into getCachedQuestions(), routes generation through the registry, and handles DataIntegrityViolationException on save by refetching cached questions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: 🐛 fix

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed 제목이 LLM 동시성 제한과 중복 작업 제어 개선이라는 핵심 변경을 잘 요약합니다.
Description check ✅ Passed 템플릿의 주요 섹션과 작업 내용은 갖추었고, 일부 체크리스트와 스크린샷·이슈 번호만 비어 있습니다.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/#113-llm-concurrency

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java (1)

79-82: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t cache limiter fallbacks as generated questions generateMockRecommendedQuestions and the other LLM entrypoints swallow GeneralException from llmConcurrencyLimiter.execute(...) and return fallback payloads. MockQuestionCacheService.createAndCacheQuestionsInternal then persists that response verbatim, so a limiter timeout can be stored as the cached questions. Re-throw the limiter exception (like AnalysisAiClient) or return a flag so callers skip caching degraded output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java`
around lines 79 - 82, The fallback handling in JobPostingAiService is treating
limiter failures as successful AI output, which lets degraded responses get
cached. Update generateMockRecommendedQuestions and the other LLM entrypoints
around llmConcurrencyLimiter.execute(...) to not swallow GeneralException;
either re-throw it like AnalysisAiClient or return an explicit failure signal so
MockQuestionCacheService.createAndCacheQuestionsInternal can skip persisting
fallback payloads. Ensure the catch block in JobPostingAiService only falls back
for true API errors, not limiter timeouts.
🧹 Nitpick comments (9)
src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java (1)

46-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Coverage gap: the new in-flight dedup path is never exercised.

getRecommendedQuestionsUsesCache hits the cache and returns before mockQuestionInflightRegistry.execute(...) is reached, and the two createAndCacheQuestions tests call that method directly (bypassing the registry). No test verifies that a cache miss in getRecommendedQuestions routes through mockQuestionInflightRegistry.execute(...). Consider adding a case that stubs the registry mock to run the supplier and asserts the miss path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java`
around lines 46 - 59, The new in-flight dedup path in MockQuestionCacheService
is not covered because the current cache-hit test returns before
MockQuestionInflightRegistry.execute is called, and the createAndCacheQuestions
tests bypass getRecommendedQuestions entirely. Add a test around
getRecommendedQuestions that forces a cache miss, stubs
mockQuestionInflightRegistry.execute to invoke the supplied action, and asserts
the method routes through that registry path before creating and caching
questions.
src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionInflightRegistry.java (1)

16-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Single-flight logic is correct; add a dedicated test for this concurrency primitive.

The leader/follower coordination via putIfAbsent, synchronous task.run(), and atomic remove(key, task) is sound. Two things worth noting:

  1. Followers waiting on existingTask.get() inherit the leader's failure — a transient failure in the single leader propagates to all concurrent callers rather than letting them retry. This is acceptable single-flight behavior, but intentional.
  2. There is no test covering this component directly (concurrent callers collapsing to one supplier invocation, exception propagation, and key cleanup). A concurrency primitive like this benefits from an explicit test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionInflightRegistry.java`
around lines 16 - 42, Add a dedicated concurrency test for
MockQuestionInflightRegistry.execute to cover the single-flight behavior
explicitly. Verify that concurrent callers with the same key collapse into one
supplier invocation, that followers on existingTask.get() receive the same
failure as the leader when supplier::get throws, and that inflightTasks is
cleaned up after completion via the remove(key, task) path. Use the execute
method and the MockQuestionInflightRegistry class as the anchors for the new
test.
src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java (1)

33-36: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Transaction stays open while blocking on the in-flight leader and the LLM call.

getRecommendedQuestions is covered by the class-level @Transactional, so on a cache miss each concurrent follower holds its DB connection open while blocked in mockQuestionInflightRegistry.execute(...) waiting for the leader's (slow, semaphore-throttled) OpenAI generation to finish. Under a burst of concurrent requests for the same key this can pin connections and pressure the pool for the duration of the external call.

Consider resolving the cache read/generation outside a long-lived transaction (e.g., non-transactional coordination + a short transaction only around the persistence step).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java`
around lines 33 - 36, The class-level transactional scope in
MockQuestionCacheService#getRecommendedQuestions keeps DB connections open while
threads wait inside mockQuestionInflightRegistry.execute and while
createAndCacheQuestions calls the LLM. Refactor this flow so cache lookup and
in-flight coordination happen outside a long-lived transaction, and keep only
the persistence portion in a short transactional boundary. Update the relevant
methods in MockQuestionCacheService (especially getRecommendedQuestions and
createAndCacheQuestions) to avoid holding the transaction across the blocking
leader/follower path.
src/main/resources/application-dev.yaml (1)

143-151: 🚀 Performance & Scalability | 🔵 Trivial

Async pool size exceeds global LLM concurrency budget.

async.llm.max-pool-size is 6 while llm.concurrency.max-concurrent-requests is 4. Since the limiter is shared globally across all OpenAI call sites (AnalysisAiClient, JobPostingAiService), up to 2 of the async worker threads will routinely block on tryAcquire and fail with SERVICE_UNAVAILABLE after the 3s timeout once the pool saturates, rather than being naturally queued by the executor. Worth confirming this is the intended backpressure behavior, or sizing max-concurrent-requests to match/exceed async.llm.max-pool-size.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/application-dev.yaml` around lines 143 - 151, The async
LLM executor can outgrow the shared global concurrency limit, causing worker
threads to block and time out in the OpenAI call path. Review the
`llm.core-pool-size`, `llm.max-pool-size`, and
`llm.concurrency.max-concurrent-requests` settings in `application-dev.yaml`,
and either align `max-concurrent-requests` with or above the async pool size or
intentionally document the desired backpressure behavior for `AnalysisAiClient`
and `JobPostingAiService`.
src/main/resources/application-prod.yaml (1)

155-163: 🚀 Performance & Scalability | 🔵 Trivial

Same async-pool vs. concurrency-limit mismatch as application-dev.yaml.

async.llm.max-pool-size (6) exceeds llm.concurrency.max-concurrent-requests (4) here too. In production this means the executor can dispatch more concurrent LLM-related tasks than the semaphore will admit, so some requests will wait out acquire-timeout-millis and surface as SERVICE_UNAVAILABLE under moderate concurrency rather than actual OpenAI overload. Recommend aligning these two knobs (or documenting the intentional backpressure) before rollout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/application-prod.yaml` around lines 155 - 163, The
production LLM async executor and concurrency limit are mismatched, so the pool
can queue more work than `llm.concurrency.max-concurrent-requests` allows.
Update the `llm` settings in `application-prod.yaml` so
`core-pool-size`/`max-pool-size` are aligned with
`llm.concurrency.max-concurrent-requests`, or explicitly document the
intentional backpressure behavior. Use the `llm` and `llm.concurrency` sections
to locate the settings.
src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java (1)

56-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No coverage verifies the limiter interaction or operation keys.

Tests only add the mock for constructor wiring; none verify llmConcurrencyLimiter.execute(...) is invoked with the correct per-operation key (e.g. "job-posting-generate", "mock-question-generate"), nor that a limiter-thrown exception is handled as expected. Since operation keys are plain string literals with no compile-time check, a copy-paste mistake across the 5 call sites would go undetected.

Consider adding verify(llmConcurrencyLimiter).execute(eq("mock-question-generate"), any())-style assertions, and/or stubbing execute to invoke the supplied lambda (thenAnswer(inv -> ((LlmConcurrencyLimiter.CheckedSupplier<?>) inv.getArgument(1)).get())) to exercise the real success path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java`
around lines 56 - 69, The current tests only wire in llmConcurrencyLimiter but
do not verify its execute interaction or the per-operation key literals used by
JobPostingAiService. Update JobPostingAiServiceTest to assert that
llmConcurrencyLimiter.execute is called with the expected keys for each relevant
path (for example the generate/question flows), and add a stub on execute that
runs the supplied CheckedSupplier so the real success path is exercised. Also
add a case that makes execute throw to confirm JobPostingAiService handles
limiter failures as intended.
src/main/java/com/jobdri/jobdri_api/global/config/LlmConcurrencyLimiter.java (1)

55-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Unwrap already-unchecked exceptions instead of re-wrapping.

catch (Exception e) { throw new RuntimeException(e); } re-wraps exceptions that are already RuntimeExceptions (e.g. OpenAI SDK errors), losing the original type and muddying e.getMessage() for callers that log it (e.g. AnalysisAiClient/JobPostingAiService both do log.error("...: {}", e.getMessage(), e)). During incident diagnosis this matters.

♻️ Proposed fix
-        } catch (Exception e) {
-            throw new RuntimeException(e);
-        }
+        } catch (RuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/jobdri/jobdri_api/global/config/LlmConcurrencyLimiter.java`
around lines 55 - 58, The exception handling in LlmConcurrencyLimiter is
re-wrapping already-unchecked errors in a new RuntimeException, which hides the
original exception type and message. Update the catch(Exception e) path so
RuntimeException instances are propagated as-is, and only wrap checked
exceptions; keep GeneralException handling unchanged and preserve the original
throwable for callers like AnalysisAiClient and JobPostingAiService that log
e.getMessage() and the stack trace.
src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java (1)

60-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate "already in progress" response construction.

This block duplicates the response-building logic already present in submit() (Lines 27-33). Extracting a small helper (e.g. toInProgressResponse(AnalysisAsyncTask task)) would keep both paths' status/message in sync if either changes later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java`
around lines 60 - 67, The in-progress response is built twice in
AnalysisAsyncFacadeService, once in submit() and again in the
createPendingTask() failure path, which risks them drifting apart. Extract the
shared response construction into a helper such as
toInProgressResponse(AnalysisAsyncTask task) and use it from both submit() and
the pendingTaskResult.created() false branch so the taskId, status, and message
stay consistent.
src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java (1)

43-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage of the conflict/fallback path; consider an edge-case test too.

This test correctly verifies the primary conflict path where findActiveTask finds the existing task after createPendingTask throws. Consider adding a companion test where findActiveTask returns empty on the retry (simulating the task completing/being deleted in the race window), asserting that the original DataIntegrityViolationException propagates instead of being swallowed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java`
around lines 43 - 62, The current test only covers the fallback path where
AnalysisAsyncFacadeService.submit recovers by re-reading an existing task after
createPendingTask fails. Add a companion test around submit, findActiveTask, and
createPendingTask that simulates the retry still returning empty (the race
window where the task disappears/completes), and assert that the original
DataIntegrityViolationException is propagated instead of returning a response or
swallowing the error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java`:
- Around line 86-98: The catch in createPendingTask is too broad and treats
every DataIntegrityViolationException as a duplicate active task. Narrow the
handling in AnalysisAsyncFacadeService#createPendingTask by checking that the
failure is specifically the unique-constraint case for the pending task creation
(for example via the constraint name from the most specific cause) before
falling back to findActiveTask. Let unrelated integrity violations propagate so
only the intended duplicate-task path is converted into a PendingTaskResult with
false.

In
`@src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java`:
- Around line 59-72: The fallback in MockQuestionCacheService cannot reliably
read the cached row after a DataIntegrityViolationException because the
surrounding `@Transactional` scope is still marked rollback-only after
mockQuestionCacheRepository.save(...) fails. Update the retry path in
MockQuestionCacheService, especially getCachedQuestions(request) and the
save/catch block around MockQuestionCache.create(...), so the re-read happens in
a fresh transaction or only after the failed write has been rolled back,
ensuring the existing row can be returned consistently.

In
`@src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java`:
- Around line 133-152: The mocked save() conflict path in
MockQuestionCacheServiceTest does not reproduce the real transaction/session
failure behavior, so the fallback query is not being validated accurately. Move
this coverage from a pure Mockito unit test to a `@DataJpa-like` or integration
test that uses a real datasource and exercises
MockQuestionCacheService.createAndCacheQuestions with the repository conflict
path, so the retry/fallback behavior after DataIntegrityViolationException is
tested under real transaction semantics.

---

Outside diff comments:
In
`@src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java`:
- Around line 79-82: The fallback handling in JobPostingAiService is treating
limiter failures as successful AI output, which lets degraded responses get
cached. Update generateMockRecommendedQuestions and the other LLM entrypoints
around llmConcurrencyLimiter.execute(...) to not swallow GeneralException;
either re-throw it like AnalysisAiClient or return an explicit failure signal so
MockQuestionCacheService.createAndCacheQuestionsInternal can skip persisting
fallback payloads. Ensure the catch block in JobPostingAiService only falls back
for true API errors, not limiter timeouts.

---

Nitpick comments:
In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java`:
- Around line 60-67: The in-progress response is built twice in
AnalysisAsyncFacadeService, once in submit() and again in the
createPendingTask() failure path, which risks them drifting apart. Extract the
shared response construction into a helper such as
toInProgressResponse(AnalysisAsyncTask task) and use it from both submit() and
the pendingTaskResult.created() false branch so the taskId, status, and message
stay consistent.

In
`@src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java`:
- Around line 33-36: The class-level transactional scope in
MockQuestionCacheService#getRecommendedQuestions keeps DB connections open while
threads wait inside mockQuestionInflightRegistry.execute and while
createAndCacheQuestions calls the LLM. Refactor this flow so cache lookup and
in-flight coordination happen outside a long-lived transaction, and keep only
the persistence portion in a short transactional boundary. Update the relevant
methods in MockQuestionCacheService (especially getRecommendedQuestions and
createAndCacheQuestions) to avoid holding the transaction across the blocking
leader/follower path.

In
`@src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionInflightRegistry.java`:
- Around line 16-42: Add a dedicated concurrency test for
MockQuestionInflightRegistry.execute to cover the single-flight behavior
explicitly. Verify that concurrent callers with the same key collapse into one
supplier invocation, that followers on existingTask.get() receive the same
failure as the leader when supplier::get throws, and that inflightTasks is
cleaned up after completion via the remove(key, task) path. Use the execute
method and the MockQuestionInflightRegistry class as the anchors for the new
test.

In
`@src/main/java/com/jobdri/jobdri_api/global/config/LlmConcurrencyLimiter.java`:
- Around line 55-58: The exception handling in LlmConcurrencyLimiter is
re-wrapping already-unchecked errors in a new RuntimeException, which hides the
original exception type and message. Update the catch(Exception e) path so
RuntimeException instances are propagated as-is, and only wrap checked
exceptions; keep GeneralException handling unchanged and preserve the original
throwable for callers like AnalysisAiClient and JobPostingAiService that log
e.getMessage() and the stack trace.

In `@src/main/resources/application-dev.yaml`:
- Around line 143-151: The async LLM executor can outgrow the shared global
concurrency limit, causing worker threads to block and time out in the OpenAI
call path. Review the `llm.core-pool-size`, `llm.max-pool-size`, and
`llm.concurrency.max-concurrent-requests` settings in `application-dev.yaml`,
and either align `max-concurrent-requests` with or above the async pool size or
intentionally document the desired backpressure behavior for `AnalysisAiClient`
and `JobPostingAiService`.

In `@src/main/resources/application-prod.yaml`:
- Around line 155-163: The production LLM async executor and concurrency limit
are mismatched, so the pool can queue more work than
`llm.concurrency.max-concurrent-requests` allows. Update the `llm` settings in
`application-prod.yaml` so `core-pool-size`/`max-pool-size` are aligned with
`llm.concurrency.max-concurrent-requests`, or explicitly document the
intentional backpressure behavior. Use the `llm` and `llm.concurrency` sections
to locate the settings.

In
`@src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java`:
- Around line 43-62: The current test only covers the fallback path where
AnalysisAsyncFacadeService.submit recovers by re-reading an existing task after
createPendingTask fails. Add a companion test around submit, findActiveTask, and
createPendingTask that simulates the retry still returning empty (the race
window where the task disappears/completes), and assert that the original
DataIntegrityViolationException is propagated instead of returning a response or
swallowing the error.

In
`@src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java`:
- Around line 56-69: The current tests only wire in llmConcurrencyLimiter but do
not verify its execute interaction or the per-operation key literals used by
JobPostingAiService. Update JobPostingAiServiceTest to assert that
llmConcurrencyLimiter.execute is called with the expected keys for each relevant
path (for example the generate/question flows), and add a stub on execute that
runs the supplied CheckedSupplier so the real success path is exercised. Also
add a case that makes execute throw to confirm JobPostingAiService handles
limiter failures as intended.

In
`@src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java`:
- Around line 46-59: The new in-flight dedup path in MockQuestionCacheService is
not covered because the current cache-hit test returns before
MockQuestionInflightRegistry.execute is called, and the createAndCacheQuestions
tests bypass getRecommendedQuestions entirely. Add a test around
getRecommendedQuestions that forces a cache miss, stubs
mockQuestionInflightRegistry.execute to invoke the supplied action, and asserts
the method routes through that registry path before creating and caching
questions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 948feb52-c501-404d-b4f7-9420585bfd69

📥 Commits

Reviewing files that changed from the base of the PR and between 07ba965 and 581f976.

📒 Files selected for processing (14)
  • ops/db/migrations/20260704_analysis_async_tasks_active_unique.sql
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java
  • src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java
  • src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java
  • src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java
  • src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionInflightRegistry.java
  • src/main/java/com/jobdri/jobdri_api/global/config/LlmConcurrencyLimiter.java
  • src/main/resources/application-dev.yaml
  • src/main/resources/application-prod.yaml
  • src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java
  • src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java
  • src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java
  • src/test/resources/application-test.yaml

Comment on lines +133 to +152
when(mockQuestionCacheRepository.findByCompany_IdAndDetailClassification_IdAndPromptVersion(
1L,
100L,
MockQuestionCacheService.PROMPT_VERSION
))
.thenReturn(Optional.empty())
.thenReturn(Optional.of(savedCache));
when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification));
when(companyRepository.findById(1L)).thenReturn(Optional.of(company));
when(jobPostingAiService.generateMockRecommendedQuestions(
org.mockito.ArgumentMatchers.eq(request),
org.mockito.ArgumentMatchers.eq(company)
)).thenReturn(aiResponse);
when(mockQuestionCacheRepository.save(org.mockito.ArgumentMatchers.any(MockQuestionCache.class)))
.thenThrow(new DataIntegrityViolationException("duplicate cache"));

List<String> questions = mockQuestionCacheService.createAndCacheQuestions(request);

assertThat(questions).containsExactly("질문 A", "질문 B");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test cannot validate the real conflict-recovery behavior.

Because the repository is a mock, save(...) throwing DataIntegrityViolationException does not abort a transaction or invalidate a Hibernate session, so the stubbed second findByCompany_IdAndDetailClassification_IdAndPromptVersion(...) returns cleanly. In production the fallback query runs inside the aborted transaction (see the service comment) and would fail, so this green test gives false confidence in the recovery path. A @DataJpalike/integration test hitting a real datasource is needed to actually cover the constraint-conflict fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java`
around lines 133 - 152, The mocked save() conflict path in
MockQuestionCacheServiceTest does not reproduce the real transaction/session
failure behavior, so the fallback query is not being validated accurately. Move
this coverage from a pure Mockito unit test to a `@DataJpa-like` or integration
test that uses a real datasource and exercises
MockQuestionCacheService.createAndCacheQuestions with the repository conflict
path, so the retry/fallback behavior after DataIntegrityViolationException is
tested under real transaction semantics.

@shinae1023 shinae1023 merged commit c3559c9 into dev Jul 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant