확장 안전 공고 수집 배치 TDD

Background

근거 PRD: 20260722-타깃-공고-알림-및-지원-히스토리-prd.md의 FR-9, FR-12, FR-14~15, FR-19, FR-60, FR-66, FR-69, NFR-2, Operations. 애그리게이터 검색 하나가 수천 건까지 커질 수 있는데 현재 구현은 활성 소스를 전부 순차 순회하고, 외부 HTTP 호출과 DB 저장을 소스당 하나의 REQUIRES_NEW 트랜잭션으로 묶는다. 원티드의 links.next 및 사람인의 총 건수 기반 반복에는 총 페이지 상한도 없다.

Overview

메시지 브로커·별도 워커 프로세스 없이 MySQL을 내구성 있는 작업 큐로 사용한다. 자정의 단발 수집은 매분 동작하는 dispatcher가 만드는 일일 소스 수집 사이클로 바뀐다. 사이클은 짧은 lease 작업(slice)으로 나뉘며, slice 하나는 설정된 페이지·공고·상세조회·시간 예산을 넘지 않는다. HTTP는 어떤 DB 트랜잭션에도 포함하지 않으며, 결과 저장·cursor 전진·lease 해제만 짧은 트랜잭션으로 처리한다.

기본 운영값(환경변수로 재정의 가능): tick당 claim 10개, slice당 목록 5페이지/500공고/상세 50건/5분, 일일 사이클당 목록 50페이지/5,000공고/상세 300건, lease 10분. 일일 총 예산을 소진하면 BUDGET_EXHAUSTED로 끝내며 마감 델타를 절대 실행하지 않는다. 다음 날 최신순 첫 페이지부터 다시 시작한다. 이는 FR-69의 소스별 1일 1회 정책과 최신 공고 우선성을 유지하면서 무한 호출을 막는 보수적 선택이다.

Terminology

용어정의
일일 사이클KST 일자·jobSourceId에 유일한 논리 수집 단위. 자정 creation 시 NOT_STARTED로 내구 저장
slice하나의 worker lease가 처리하는 제한된 목록/상세 요청 묶음
cursor다음 slice가 이어받을 플랫폼별 페이지 또는 cursor 값
완전 수집nextCursor=null까지 도달한 사이클
safe close predicatecycleStatus=SUCCESS && isComplete=true && fetchedCount>0; 이 조건일 때만 미발견·마감 델타 허용
budget exhausted안전 상한 때문에 완전 수집 전에 종료한 정상 보호 상태
lease특정 인스턴스가 짧은 시간 동안 작업을 소유했다는 DB 기반 권한

Define Problem

AS-IS

  • CollectJobPostingsUseCase#executeCollectAggregatorPostingsUseCase#execute가 모든 활성 소스를 메모리 목록으로 읽어 순차 실행한다.
  • 두 UseCase의 perSourceTransaction 안에서 JobPostingCollectionDomainService#collect/AggregatorCollectionDomainService#fetchJobSourceGateway#collect를 호출한다. 즉 네트워크 대기와 DB 트랜잭션이 같은 경계다.
  • WantedAggregatorAdapter#collectAllPages는 next URL이 없어질 때까지, SaraminAggregatorAdapter#collect는 총 건수가 찰 때까지 반복한다. 점핏만 maxPageCount가 있다.
  • JobPostingCollectionRunSUCCESS/FAILED 결과만 표현하고 (job_source_id, run_date) 유니크로 당일 재실행을 막는다. 진행 중 cursor·lease·부분 카운터가 없다.
  • SourceRequestExecutor#awaitRequestDelay는 프로세스 메모리의 host별 마지막 호출만 보므로 다중 인스턴스에서 host 간격을 보장하지 않는다.
  • OperationApiController#listCollectionRuns는 종료 이력만 조회하며 slice/예산/지연/재개 상태를 노출하지 않는다.

TO-BE

일일 사이클 행을 먼저 원자적으로 만들고, worker가 FOR UPDATE SKIP LOCKED 또는 조건부 UPDATE로 한 행만 lease한다. 한 slice가 HTTP로 얻은 페이지는 종료 후 별도 저장 트랜잭션에서 반영한다. 완료 전에는 seen sourceJobId를 기록하고, 마지막 slice에서만 미발견 델타를 수행한다. 외부 HTTP 오류·lease 만료는 같은 KST 날짜에 재claim하지 않고 즉시 terminal FAILED로 기록한다. stale worker는 lease token 불일치로 결과를 저장할 수 없다.

Architecture Benchmarking

제품/사례해결 방식참고할 패턴미참고 사유
Spring Batch chunk processing읽은 item 묶음을 짧은 transaction boundary로 commitHTTP와 저장을 분리하고 page/slice 단위 commit이 앱은 브로커·별도 Batch 메타 스키마를 PRD가 배제하므로 프레임워크 도입 대신 기존 MySQL 스키마에 최소 상태를 둔다
ShedLock외부 저장소에 scheduler lock을 두어 다중 인스턴스의 중복 scheduled task를 조정lease 만료·외부 저장소 조정 원칙전역 잠금 하나만으로는 소스별 재개·부분 실패·공정 분배를 표현 못 하므로 source cycle 행 자체를 lease한다
Temporal durable executionworkflow 상태·재시도·task queue를 내구 저장실패 후 정확한 상태 재개와 activity 멱등성단일 사용자/1일 배치에 Temporal 운영은 과도하므로 같은 핵심을 관계형 큐로 제한 구현한다
Sidekiq job formatjob ID, scheduled time, retry 정보로 비동기 작업을 추적attempt·scheduled/next eligible·오류를 관측 가능한 작업 상태로 보존Redis/별도 worker는 PRD Non-Goal이고 FIFO queue를 새로 운영하지 않는다

Possible Solutions

방안설명채택 여부미채택 사유
현재 단일 스케줄러에 상한만 추가모든 소스를 한 요청에서 처리하되 어댑터 반복만 제한미채택장기 트랜잭션·중단 후 재개 불가·다중 인스턴스 중복을 해결하지 못한다
Spring Batch + 별도 JobRepositorychunk/restart를 프레임워크가 제공미채택현재 규모에는 스키마·운영 모델이 중복되고 PRD의 별도 배치 인프라 미도입 원칙에 비해 무겁다
Kafka/Redis/Temporal worker내구 큐와 분산 worker를 제공미채택처리량 요구가 낮고 PRD Non-Goal이다; 나중에 처리 시간/소스 수가 기본 예산을 반복 초과할 때 전환한다
MySQL source-cycle work queue + lease기존 MySQL에 일일 상태·cursor·seen set·host throttle을 보관하고 API 서버 scheduler가 slice 수행채택현재 스택만으로 재개·상한·다중 인스턴스 안전성을 만족하며 운영 복잡도가 가장 낮다

Detail Design

Bounded context / topology decision

새 도메인은 만들지 않고 기존 posting 컨텍스트에 합류한다. 수집 사이클은 JobPosting, JobSourceHealth, JobPostingCollectionRun과 동일한 수명·데이터 소유자이며, company 자동 등록은 계속 application 레이어가 조합한다. API 서버와 scheduler/worker는 지금은 한 Spring Boot 프로세스의 역할만 분리한다. 별도 worker 프로세스는 처리량·격리 요구가 생길 때만 채택한다.

시스템 역할 경계

단위역할소유 데이터/책임노출 인터페이스의존
CollectionCycleDomainServicecycle 상태 전이·budget·lease token 보호cycle, seen ID, 최종화 판정createDailyCycles, prepare, claim, applySlice, recoverExpiredposting repositories
CollectionSlaSnapshotDomainService09:00 SLA snapshotcutoff 시점 source eligibility와 cycle 상태를 immutable snapshot으로 보존captureAtNine, markMissedSnapshotcycle/snapshot repositories
CollectionDispatchUseCase회사/애그리게이터 source를 slice 단위로 조합lease 수명, company 매핑 호출executeTick(now, workerId)posting/company domain services
JobSourceGateway플랫폼 slice를 외부에서 읽음HTTP 결과만, DB 변경 없음collectSlice(descriptor): SourceCollectionSliceOutcomeadapter
HostRequestThrottleGatewayhost 간 최소 간격을 DB로 예약next_allowed_atreserve(host, delay, now): notBeforepersistence
collector adapterplatform cursor/페이지 해석·상한 준수HTTP 호출 및 raw mappingplatform-local clientSourceRequestExecutor
OperationApiController운영 읽기만 제공상태를 사용자 DTO로 조합GET 기존 API의 additive fieldsapplication
schedulertick 시작만 담당없음run()dispatch use case

Interface signatures

interface JobPostingCollectionCycleRepository {
    fun createIfAbsent(jobSourceId: Long, runDate: LocalDate, sourceType: SourceType): JobPostingCollectionCycle
    fun save(cycle: JobPostingCollectionCycle): JobPostingCollectionCycle
    fun claimReady(activeJobSourceIds: List<Long>, maxItems: Int, workerId: String, leaseUntil: ZonedDateTime, now: ZonedDateTime): List<JobPostingCollectionCycle>
    fun renew(cycleId: Long, leaseToken: UUID, leaseUntil: ZonedDateTime): Boolean
    fun applySlice(cycleId: Long, leaseToken: UUID, result: AppliedCollectionSlice): Boolean
    fun fail(cycleId: Long, leaseToken: UUID?, reason: CollectionFailureReason, now: ZonedDateTime): Boolean
    fun exhaustBudget(cycleId: Long, leaseToken: UUID, reason: CollectionBudgetLimit, now: ZonedDateTime): Boolean
    fun recoverExpired(now: ZonedDateTime): Int
}
 
interface CollectionSlaCutoffGate {
    fun <T> withDailyWriteGate(runDate: LocalDate, action: () -> T): T
}
 
interface JobSourceGateway {
    fun collectSlice(descriptor: CollectionSliceDescriptor): SourceCollectionSliceOutcome
}
 
interface HostRequestThrottleGateway {
    fun reserve(host: String, minimumDelayMillis: Long, now: ZonedDateTime): ZonedDateTime
}

CollectionSliceDescriptor에는 jobSourceId, platform/sourceType 검색 조건, cursor, 이미 소비한 일일 budget, slice budget, known signatures를 둔다. SourceCollectionSliceOutcome.Fetchedpostings, nextCursor, listPageCount, detailRequestCount, detailFailureCount, isComplete를 가진다. adapter는 slice budget 전에 더 이상 요청하지 않으며 next cursor를 그대로 반환한다. AppliedCollectionSlice는 누적 counts와 seen IDs를 포함한다. domain은 named predicate isSafeToApplyMissedDelta() (SUCCESS && isComplete && fetchedCount > 0)가 참일 때만 final/missed/close delta를 실행한다.

클래스 역할 정의

클래스명역할핵심 책임
JobPostingCollectionCycleposting aggregateclaim, renewLease, recordSlice, complete, exhaustBudget, recoverExpired 상태 보호
CollectionBudgetvalue object0/음수 설정 거부, slice·daily 상한 비교
CollectionCycleDomainServicedomain servicesource cycle claim, slice 저장, isSafeToApplyMissedDelta()일 때만 applyMissedPostings 호출
CollectionCycleStateWriteDomainServicedomain serviceactive-source claim, lease fencing, terminal FAILED/BUDGET persistence를 daily SLA gate 안에서 수행
CollectionDispatchUseCaseapplication servicesource snapshot→claim→HTTP(무트랜잭션)→짧은 persist transaction 오케스트레이션
CollectionSliceProcessorapplication collaboratorcompany-bound/aggregator 각각의 source context 값을 descriptor로 변환; aggregator의 회사 resolve는 페이지 결과 직후 수행
PersistentHostRequestThrottleinfrastructure adapterDB 원자 예약 후 notBefore 반환; sleep은 executor가 트랜잭션 밖에서 수행

State transition

현재 상태 × 이벤트다음 상태거부/처리
NOT_STARTED × dispatcher preparePENDINGactive·지원되는 source의 아직 시작하지 않은 cycle만 ready queue에 넣음; 외부 HTTP 없음
NOT_STARTED/PENDING × source disabled그대로dispatcher claim 대상에서 제외; 외부 HTTP 없이 남은 당일 cycle은 상태를 바꾸지 않음
NOT_STARTED × descriptor/configuration failureFAILED외부 HTTP 없이 terminal failure 기록
NOT_STARTED × 09:00 SLA snapshotNOT_STARTEDimmutable snapshot에 그대로 기록; 이후 dispatcher가 계속 처리 가능
PENDING × claimRUNNINGdue이고 lease 없음일 때만 token 발급
RUNNING × slice persisted, next 있음PENDINGcursor·누적 계수 저장, next_eligible_at=now
RUNNING × complete, fetchedCount > 0SUCCESSisSafeToApplyMissedDelta()로 seen 집합 final delta 후 종료
RUNNING × complete, fetchedCount = 0SUCCESSabnormal·closeGuarded; final/missed/close delta 금지
RUNNING × daily budget 초과BUDGET_EXHAUSTEDcursor 보존, final delta/health abnormal/auto-close 금지
RUNNING × HTTP failure (403/429/5xx/timeout/policy 포함)FAILED같은 KST 날짜에 외부 HTTP 재시도 금지; 다음 KST 날짜의 새 cycle만 가능
RUNNING × lease 만료FAILEDrecovery tick가 token을 폐기하고 terminal 실패 기록; stale 결과는 거부
SUCCESS/FAILED/BUDGET_EXHAUSTED × claim그대로같은 KST 날짜에 재실행하지 않음

실패 경로·동시성·멱등

  • HTTP/DB 분리: claim·회사 매핑·결과 반영은 각각 짧은 REQUIRES_NEW; collectSlice와 throttle 대기는 transaction 밖이다. HTTP 성공 뒤 DB 저장 전 프로세스가 죽으면 lease recovery가 FAILED로 terminalize하며 같은 KST 날짜에 동일 slice를 재요청하지 않는다. 이는 외부 호출의 exactly-once 보장 대신 FR-69의 하루 1회·요청 절제 규약을 우선하는 명시적 손실-보수 정책이다.
  • 멱등: job_source_id + run_date가 cycle 식별자이며, seen table은 (cycle_id, source_job_id) 유니크다. 자정 cycle creation과 09:00 snapshot 전 보충 creation은 같은 unique key로 멱등이다. JobPosting의 기존 (job_source_id, source_job_id)와 domain refresh가 중복 slice 저장을 흡수한다. 결과 반영은 (cycleId, leaseToken) 조건부 update여야 한다.
  • 동시성: claimReady는 MySQL 8 FOR UPDATE SKIP LOCKED와 lease token으로 구현하고, 결과 반영은 expired/stale token을 0 row update로 거부한다. DB가 다중 인스턴스의 진실원천이다; in-memory scheduler pool은 보조일 뿐이다.
  • rate control: host permit 행을 짧게 잠가 next_allowed_at=max(now, next_allowed_at)+delay로 갱신한다. 반환된 시각까지 transaction 밖에서 대기한다. 소스의 기존 UA/host/path/page-size whitelist는 유지한다.
  • 비활성화와 부분 실패: dispatcher는 claim 직전과 throttle 대기 직후 source가 여전히 active인지 재확인한다. 비활성화된 NOT_STARTED/PENDING cycle은 상태를 유지한 채 claim하지 않고, RUNNING cycle은 이미 발행한 HTTP만 결과 반영할 수 있으나 다음 slice HTTP는 발행하지 않는다. 즉 disable 후 새 외부 호출은 없고, 같은 날 다시 active가 되면 기존 일일 cycle만 재개할 수 있어 FR-69의 source당 하나의 일일 cycle을 유지한다. detail 일부 실패는 현행처럼 카운트하고 slice/cycle은 진행한다. 목록 HTTP 실패(403/429/5xx/timeout/policy 포함)와 lease 만료는 즉시 FAILED이며 해당 KST 날짜에 외부 HTTP를 다시 호출하지 않는다. 0건은 complete된 cycle에서만 abnormal으로 처리한다. budget exhausted는 운영 경고 대상이나 source broken 3일 카운터에는 넣지 않는다.
  • 마감 안전: named safe predicate cycleStatus=SUCCESS && isComplete=true && fetchedCount>0일 때만 missed/close를 반영한다. complete-zero SUCCESS는 abnormal·closeGuarded이며 final delta를 실행하지 않는다. FAILED, BUDGET_EXHAUSTED, 실행 중, lease 회수 중에도 소스 가드와 동일하게 마감 판정이 없다.

Component Diagram

flowchart LR
    Scheduler --> DispatchUseCase
    DispatchUseCase --> CycleService
    CycleService --> CycleRepository
    DispatchUseCase --> JobSourceGateway
    JobSourceGateway --> Adapter
    Adapter --> RequestExecutor
    RequestExecutor --> HostThrottle
    DispatchUseCase --> CompanyService
    CycleService --> PostingRepository
    CycleService --> RunRepository
    OperationAPI --> CycleQuery

Sequence Diagram

sequenceDiagram
    participant S as Scheduler
    participant D as DispatchUseCase
    participant C as CycleService
    participant G as Gateway/Adapter
    participant H as Host throttle
    S->>D: executeTick(workerId)
    D->>C: create + claim slice (short tx)
    C-->>D: cycle + lease token
    D->>G: collectSlice(descriptor) (no DB tx)
    G->>H: reserve host interval (short tx)
    H-->>G: notBefore
    G-->>D: postings + next cursor
    D->>C: apply slice (short tx)
    C-->>D: pending/success/budget exhausted

ERD

erDiagram
    JOB_SOURCES ||--o{ JOB_POSTING_COLLECTION_CYCLES : collects
    JOB_POSTING_COLLECTION_CYCLES ||--o{ JOB_POSTING_COLLECTION_SEEN_POSTINGS : observes
    JOB_POSTING_COLLECTION_CYCLES ||--|| JOB_POSTING_COLLECTION_RUNS : exposes_history
    JOB_POSTINGS ||--o{ JOB_POSTING_COLLECTION_SEEN_POSTINGS : seen_as
    EXTERNAL_REQUEST_HOST_LIMITS ||--o{ JOB_SOURCES : throttles_host
    JOB_POSTING_COLLECTION_CYCLES {
        bigint id PK
        bigint job_source_id
        date run_date
        varchar cycle_status
        varchar continuation_cursor
        varchar lease_token
        datetime lease_until
        int list_page_count
        int fetched_count
    }

Schema needs for DBA

  1. Add job_posting_collection_cycles as the SSOT for (job_source_id, run_date): NOT_STARTED/PENDING/RUNNING/SUCCESS/FAILED/BUDGET_EXHAUSTED, cursor, lease token/until, next eligible time, slice attempt count, list-page/detail-request counts, completion flag, and budget-exhausted reason. Its (job_source_id, run_date) unique key is the sole daily execution identity.
  2. Keep existing job_posting_collection_runs as a terminal compatibility projection only. It receives one immutable terminal row after the cycle terminal transition; it never owns or stores cursor/lease/claim state, and no new collection decision reads it. The operation API derives abnormal/closeGuarded from the cycle SSOT, not this projection. Existing historical rows remain readable.
  3. Add job_posting_collection_seen_postings(cycle_id, source_job_id, created_at) unique (cycle_id, source_job_id) for safe-close-predicate-only missed detection. Terminal transition (SUCCESS after final delta only when safe predicate is true, SUCCESS complete-zero without delta, FAILED, BUDGET_EXHAUSTED) must delete its seen rows in the same short transaction. A 10:15 KST residual cleanup runs in 1,000-row chunks for terminal cycles older than 48 hours, exposes seenRowsOlderThan48Hours, and alerts when it is non-zero; cleanup never touches RUNNING rows.
  4. Add external_request_host_limits(host PK, next_allowed_at, updated_at) for cross-instance host rate coordination. It is state, not an audit log.
  5. Add immutable job_posting_collection_sla_snapshots header (snapshot_date unique, scheduled_at, capture_status=PENDING/CAPTURING/CAPTURED/MISSED/NOT_APPLICABLE, captured_at, miss_reason) and job_posting_collection_sla_snapshot_items (snapshot_id, job_source_id, captured cycle status/counts/abnormal/close_guarded; unique (snapshot_id, job_source_id)). The day header is created at 00:00 and is the durable 09:00 cutoff gate: every cycle state-write transaction briefly locks it; at 09:00 capture locks it for the full cycle-lock/item-insert transaction. Therefore every cycle state transition serializes either before or after the snapshot, making CAPTURED items the truthful DB-serialization-time state at the 09:00 KST cutoff. These rows, not current cycles, are the SLA truth source.
  6. Add indexes for ready claim on cycles (cycle_status, next_eligible_at, lease_until, run_date) and per-source history (job_source_id, run_date). DBA must produce expand-only Flyway DDL and no inline backfill DML.

API Contract

No write endpoint is introduced. GET /api/operations/collection-runs remains URL and query compatible. Each item gains nullable/additive fields:

{
  "cycleStatus": "NOT_STARTED|PENDING|RUNNING|SUCCESS|FAILED|BUDGET_EXHAUSTED",
  "isComplete": true,
  "listPageCount": 12,
  "detailRequestCount": 100,
  "attemptCount": 1,
  "nextEligibleAt": "2026-07-30T00:22:00+09:00",
  "leaseUntil": null,
  "budgetExhausted": false,
  "continuationPending": false,
  "abnormal": false,
  "closeGuarded": false
}

runStatus stays for old clients (SUCCESS/FAILED; in-progress states are represented by cycleStatus). abnormal=true only for FAILED or complete SUCCESS with fetchedCount=0; BUDGET_EXHAUSTED is not abnormal. closeGuarded=true for NOT_STARTED, PENDING, RUNNING, FAILED, BUDGET_EXHAUSTED, and complete-zero SUCCESS; it is false only for complete non-zero SUCCESS. FE must render the server-supplied closeGuarded as “마감 판정 제외” and must not infer it from abnormal or reimplement the status mapping.

NOT_STARTED is a persisted cycle state, created for each eligible source at 00:00 KST and idempotently filled for an eligible source missing a cycle immediately before the 09:00 snapshot. It is not a query-time invented status. Disabled sources are excluded from claim and snapshot eligibility; they do not create a CANCELLED state or mutate an existing cycle solely because they were disabled.

The current-cycle summary is intentionally separate from the persistent 09:00 KST SLA snapshot. The 00:00-created daily snapshot header is a durable cutoff gate: every cycle state-write transaction locks it briefly, and the 09:00 capture holds it while it locks eligible cycles, idempotently creates any missing NOT_STARTED cycles, and writes immutable item rows. Thus a state update is serialized either before or after capture; CAPTURED values truthfully represent the DB-serialization-time 09:00 KST cutoff, not a later current query. Eligibility is active company-bound sources when posting.collection-dispatch-v2 is ON plus active aggregators only when that flag and aggregator.collection are ON, evaluated in the snapshot transaction in Asia/Seoul. The header unique date key makes multi-instance capture idempotent.

The API returns slaSnapshot with this full contract: { snapshotDate: LocalDate, scheduledAt: ZonedDateTime, captureStatus: "PENDING"|"CAPTURED"|"MISSED"|"NOT_APPLICABLE"|"UNAVAILABLE", capturedAt: ZonedDateTime|null, missReason: String|null, lateBeforeNotificationCount: Int|null, items: [{ jobSourceId: Long, sourceLabel: String, cycleStatus: "NOT_STARTED"|"PENDING"|"RUNNING"|"SUCCESS"|"FAILED"|"BUDGET_EXHAUSTED", isComplete: Boolean, fetchedCount: Int, abnormal: Boolean, closeGuarded: Boolean }]|null }. DB-internal CAPTURING is never exposed; API continues to return client-safe PENDING until its atomic capture commits. For CAPTURED, lateBeforeNotificationCount is the snapshot-item count whose captured status is NOT_STARTED, PENDING, RUNNING, FAILED, or BUDGET_EXHAUSTED; items are those immutable per-source statuses, never current cycles. Before 09:00 API returns PENDING, count 0, and absent items. For MISSED, NOT_APPLICABLE, or UNAVAILABLE, the count and items are null, never 0 or a late current-state calculation. A recovery tick after 09:00 must write MISSED only, with no post-cutoff reconstruction or CATCH_UP mode, if capture did not occur at the cutoff; this is required for truthful reporting. Before the first v2 capture and for pre-deployment dates, API returns NOT_APPLICABLE/UNAVAILABLE, with no backfill.

Testing Plan

LevelCoverage
domainbudget boundaries, NOT_STARTED lifecycle, state matrix, stale token rejection, safe-close predicate including complete-zero rejection, idempotent seen IDs
applicationsource cap/fair claim, no transaction during gateway call, same-day HTTP failure terminalization, company resolution page path
infrastructureMySQL concurrent claim, expired lease recovery, host throttle across two repository instances, adapter cursor/page caps
presentationadditive operation contract, 09:00 snapshot-before-notification ordering, and scheduler only triggers a tick
scenariotwo app instances claim one source once; crash after HTTP leaves terminal FAILED until next KST day without replay; 09:00 snapshot remains immutable after a 09:01 completion; 2,594-result source becomes bounded slices and does not false-close

Release Scenario — 무중단 배포

  1. Expand: DBA adds job_posting_collection_cycles, seen/throttle/SLA snapshot tables and indexes. Existing job_posting_collection_runs remains unchanged and old schedulers still write its valid SUCCESS/FAILED terminal history. Rollback: new tables/indexes can remain; old binary ignores them.
  2. Dark deploy: deploy code with posting.collection-dispatch-v2=false; legacy schedulers continue. New operation fields are nullable. Rollback: flag remains OFF.
  3. Seed/activate: set v2 ON, first tick creates current-day NOT_STARTED cycles only for sources that have no legacy run. Disable legacy two collection schedulers before v2 scheduler ON to avoid dual outbound collection. At 09:00 run the durable SLA snapshot transaction before notification dispatch. Rollback: turn v2 OFF; let leased work expire, then re-enable legacy only on the next KST day (same-day one-per-source constraint prevents unsafe replay).
  4. Observe: verify no active lease past 10 minutes, no BUDGET_EXHAUSTED on company-bound sources, host interval metric meets policy, seenRowsOlderThan48Hours=0, and a CAPTURED 09:00 snapshot exists before notification dispatch; MISSED is an explicit operational incident, never silently replaced by later current status.
  5. Contract: after 30 days of stable operation, remove legacy scheduler code in a separate contract release; do not drop history columns/tables in this feature.

Data migration plan

No backfill is needed. New cycle state begins on deployment day. Existing terminal run history remains immutable; Flyway performs DDL only.

Ticket DAG and Single Writer Check

Wave티켓너비같은 wave 수정 경로 교집합
0DBA cycle/seen/throttle/SLA migration, BE-332없음 — DBA는 migration, BE-33은 domain/posting/** 계약
1BE-35, BE-36, BE-37, BE-38, BE-39, BE-42, BE-447없음 — executor/throttle, 플랫폼별 adapter 패키지 4개, company-bound adapter 패키지, cleanup worker, cycle persistence/state-write 패키지로 분리
2BE-431BE-44의 CollectionSlaCutoffGate domain port 구현을 제공
3BE-34, BE-402없음 — dispatcher가 state-write/gate contract를 소비, operation mapper가 snapshot query를 소비
4BE-411의도된 단일 통합 — application.yml, scheduler, 공통 wiring

BE-34는 새 dispatcher 경로만, BE-39는 회사 종속형 adapter/domain delta 경로만 변경한다. BE-36~38은 각각 서로 다른 aggregator adapter 디렉터리만 수정한다. 공통 JobSourceGateway/config/scheduler 파일 수정은 BE-33 또는 BE-41로 고정해 같은 wave 충돌을 피한다.

Requirements Coverage

PRD/검토 요구설계 요소구현 티켓
FR-9/FR-60 활성 소스 수집tick claim 상한·source cycle queueBE-33, BE-34, BE-41
FR-12/FR-14/FR-15 마감 안전safe-close predicate (SUCCESS && complete && fetched>0)·BUDGET_EXHAUSTED/complete-zero guardBE-33, BE-39
FR-19 소스 고장complete-zero/FAILED만 health abnormal, partial detail failure 격리BE-33, BE-34
FR-66 전체 저장page slice별 idempotent 저장BE-33, BE-34, BE-36~39
FR-69 rate/UA/page 규약DB host throttle + adapter page/detail budgetsBE-35, BE-36~39, BE-41
NFR-2 09:00 이전 운영 가시성exact KST late predicate·run summary·closeGuardedBE-40, BE-41
NFR-2 09:00 SLA 진실성immutable snapshot, cutoff missed 처리, NOT_STARTED 보충 생성BE-33, BE-40, BE-43
BE-34 영속 state-write 선행 계약active claim·lease fencing·terminal persistence·SLA gate portBE-44
수집 seen 데이터 보존 상한terminal delete + 48시간 residual cleanup/metricBE-33, BE-42
다중 인스턴스 미래 대응MySQL claim/lease token/expiry recoveryBE-33, BE-34, BE-35
Operations 이력 조회additive API contractBE-40

Open Questions

  1. Product owner must set production daily budget per platform after the first week of measured page/detail/request counts; the defaults above are safety caps, not a claim of platform quota.
  2. If a source is repeatedly BUDGET_EXHAUSTED, choose either narrower registered search conditions or a future cursor-across-days coverage cycle; that latter design needs a separate product decision because it changes freshness/completeness semantics.

Document History

날짜변경 내용
2026-07-30최초 작성 — 대량 수집 상한, durable slice queue, lease recovery, DB host throttle, 운영 계약