재시작에 안전하고 중복이 없는 방식: Baize는 장시간 실행되는 Agent Run을 어떻게 재개 가능한지
요약
Baize는 장시간 실행되는 Agent Run의 신뢰성 문제를 해결하는 아키텍처를 제시합니다. 기존 메모리 기반 프로세스는 오류에 취약하지만, Baize는 Run을 데이터베이스의 일급 시민으로 처리하여 작업 손실이나 중복 실행 없이 안정적으로 재개할 수 있게 합니다.
핵심 포인트
- Run은 메모리 내 goroutine이 아닌 DB의 일급 행(row)으로 관리되어 신뢰성을 확보합니다.
- 상태 머신은 `queued` → `running` → `waiting for external signal`로 명확하게 정의됩니다.
- 승인 대기 등 모든 중단 상태는 단일 suspension payload에 저장하여 관리가 용이합니다.
- 큐, DB, 이벤트 버스 등이 교체 가능한 드라이버 구조를 채택해 특정 기술 종속성을 제거했습니다.
Agent Run은 단일 HTTP 요청이 아닙니다. 다음과 같은 작업을 수행할 수 있습니다:
- 여러 도구를 연속으로 호출하며 몇 분 동안 실행될 수 있습니다.
- 인간의 승인이 필요한 쓰기 작업에 도달할 수 있으며, 이 대기 시간은 몇 시간에 달할 수 있습니다.
- 예를 들어, 매일 오전 9시 요약과 같은 스케줄에 의해 트리거될 수 있습니다.
- 그룹화된 하위 작업을 활성화한 후에는 경계가 있는 수의 그룹 범위 자식 Run을 생성하여(단일 레벨만), 서로 다른 비즈니스 시스템의 도구 인터페이스와 승인 흐름을 격리할 수 있습니다.
- 완료된 후, 결과를 IM으로 다시 푸시하거나 비즈니스 시스템에 콜백할 수 있습니다.
이러한 체인 과정에서 프로세스는 재시작되고, 여러 복제본(replicas)으로 확장되며, 다운스트림 API는 시간 초과가 발생하고, 브라우저는 연결이 끊어집니다. 만약 Run이 메모리 내의 단순한 goroutine이었다면, 작은 문제에도 작업이 손실되거나 두 번 실행될 것입니다.
Baize는 이러한 종류의 런타임 신뢰성(runtime reliability) 문제를 런타임 계층에서 처리합니다. 핵심 아이디어는 한 문장으로 요약됩니다: Run을 메모리 내의 goroutine이 아닌 데이터베이스의 일급 시민(first-class) 행으로 만듭니다. 큐와 워커는 언제든지 재시작되거나 자유롭게 확장될 수 있으며,
| Type | 의미 |
|---|---|
| Normal run | 채팅 턴 시작, 채널 메시지 처리, 예약된 트리거 등입니다. |
동시에 모든 Run은 primary datastore에 행(row)을 가지고 있으며, 상태 머신(state machine)은 직관적으로 다음과 같이 읽을 수 있습니다:
(때로는) queued → running → succeeded / failed / cancelled / rejected
running ⇄ waiting for external signal
채팅에서 새로 생성된 Run은 보통 바로 running 상태로 이동하며, queued는 주로 충돌 복구 및 재큐잉 시나리오에서 나타납니다. 중요한 상태는 중간 단계인 waiting for external signal (내부 이름 waiting_human)입니다. 승인이 필요하든, 다운스트림(downstream) 로그인이 필요하든, 또는 상위 프로세스가 하위 프로세스의 수신을 기다리든, Run은 이 단일의 일시 중지 상태에 진입합니다. 이때 이유(승인 / 로그인 / 하위 프로세스 수신)는 여러 개의 비호환적인 상태 이름으로 분할되는 대신 suspension payload에 저장됩니다.
Worker들은 기본 동시성(concurrency) 8로 큐에서 Job을 가져옵니다. 큐, 이벤트 버스(event bus), 레이트 리미터(rate limiter), primary datastore, 그리고 blob storage 모두 **교체 가능한 드라이버(swappable drivers)**를 거칩니다. 이는 통일된 인터페이스를 구현하고 등록한 다음, 설정에서 드라이버 이름을 변경하는 방식으로 작동합니다. 내장 드라이버는 다음과 같습니다:
-
Primary datastore: 기본값은 SQLite이며, Postgres와 인메모리(in-memory) 옵션도 지원합니다.
-
Queue / event bus / rate limiter: 기본값은 인메모리이며, 다중 복제(multi-replica) 설정의 경우 Redis를 사용합니다.
-
Blob: 로컬 파일, S3, 인메모리를 지원합니다.
이 아키텍처는 특정 데이터베이스나 메시지 미들웨어에 종속되지 않습니다. 만약 팀이 이미 MySQL, Kafka 등을 운영하고 있다면, 오케스트레이션 코어(orchestration core)를 건드리지 않고도 동일한 드라이버 계약을 확장할 수 있습니다. 내장 구현체들은 '오늘 작동하는 하위 집합'일 뿐, 상한선(upper bound)은 아닙니다.
이 글 전체를 관통하는 하나의 설계 트레이드오프가 있습니다: 큐는 전달만 담당하며 진실의 원천(source of truth)이 아닙니다. primary datastore에 있는 Run 행이야말로 항상 진실의 원천입니다. 모든 복구 로직은 'DB 쿼리 → 상태 변경 → 재큐잉'으로 요약됩니다.
2. Lease + Heartbeat: 복제본(replicas) 간에 하나의 Run이 정확히 한 번만 실행되는 방법
시스템을 확장하여 배포할 경우, 동일한 Job이 여러 복제본에게 동시에 보일 수 있습니다. Baize는 워커 내부의 프로세스 잠금(in-process lock) 대신 **데이터베이스 리스(database lease)**를 사용하여 원자적(atomic) 클레임을 수행합니다. 실제로 시작하기 전에 실행기(executor)는 Run에 대해 '리스'를 시도하며, 이는 다음 SQL 업데이트와 의미적으로 동일합니다:
UPDATE runs SET lease_until = now + ttl
WHERE id = ?
AND status IN ('queued','running','waiting_human')
...
이 업데이트는 리스가 비어 있거나 이미 만료된 경우에만 일치하므로, 오직 하나의 클레임만이 성공합니다. 기본 매개변수는 다음과 같습니다:
-
리스 TTL(Time To Live)은 60초입니다.
-
실행 중에는 하트비트(heartbeat)가 약 20초마다 리스를 갱신하여, "나 살아있고 여전히 실행 중이다"라는 것을 증명합니다 (TTL의 3분의 1).
-
리스는 실행이 완료되면 해제됩니다.
-
클레임에 실패할 경우(다른 복제본이 유효한 리스를 보유하고 있는 경우), 워커는 이를 건너뛰고 아무것도 하지 않습니다.
결과: 동일한 Run이 재큐(re-enqueue)되어 여러 복제본에게 전달되더라도, 실제로 실행하는 것은 오직 하나뿐입니다.
3. Reconciler: 워커 충돌 후 고아(orphan) Runs 자동 재개
워커가 충돌하면, 해당 워커가 클레임했던 Run들이 여전히 running 상태에 머물러 있고 리스 역시 그 이름 아래 남아 있을 수 있습니다. **리컨사일러(reconciler)**가 이를 처리합니다.
시작 시 즉시 한 번의 스캔을 실행하고, 이후 약 15초마다 스캔을 수행합니다. 각 라운드에서:
-
리스가 만료되었거나 (또는 생성된 지 약 30초 이상 되었고 클레임되지 않은)
queued/running상태에 남아 있는 고아 Run을 찾습니다. -
각 고아 Run에 대해 "정상 실행(normal run)" 작업을 재큐합니다.
-
배치당 약 100개를 처리하고, 나머지는 다음 라운드에 남겨둡니다.
이 30초의 유예 기간은 방금 생성되어 아직 클레임되지 않은 정상 Run을 포착하는 것을 방지하여 오탐(false positives)을 줄입니다.
참고: "외부 신호 대기 중(waiting for external signal)" 상태에 머물러 있는 Run은 고아로 간주되거나 강제로 재실행되지 않습니다. 이들은 승인, 로그인 또는 자식 수신(child receipt)을 기다리는 것이며 전용 재개 작업(dedicated resume jobs)을 거칩니다.
왜 중복 실행이 없을까요? 두 가지 계층 때문입니다:
-
건강하게 실행되는 Run은 자신의 lease를 계속 유지(heartbeating)하므로, reconciler가 이를 가져가지 않습니다.
-
극단적인 경우에 재큐(re-enqueued)되더라도, 해당 실행 항목(execution entry)에는 세 가지 게이트가 있습니다: 이미 터미널 상태인 경우 → 건너뜀(skip); 사람의 승인을 기다리고 있지만 일반 Run 작업이 들어온 경우 → 건너뜀(skip); lease를 획득하는 데 실패한 경우 → 건너뜀(skip).
효과: 프로세스가 충돌하더라도, 해당 작업은 보통 한 번의 reconcile 사이클 내에서 DB 브레이크포인트(DB breakpoint)부터 재개되며, 이중 시작(double-starting)되지 않습니다.
4. 종료가 진행 중인 작업을 죽이지 않음; 승인은 재시작에도 살아남는다
배포 재시작 시 진행 중인 Run은 어떻게 되나요?
각 Job은 **독립적인 컨텍스트(independent context)**에서 실행되며, 의도적으로 “새 작업 가져오기(pull new jobs)” 생명주기에 묶이지 않습니다. 종료 시, 워커는 먼저 새로운 작업을 받지 않도록 중단하고 약 5초의 유예 시간(grace window)을 허용합니다: 짧은 작업은 가능하다면 완료되고; 승인을 위해 오랫동안 일시 정지된 Run은 인-프로세스 대기 goroutine을 잃을 수 있지만, 주 DB의 해당 행은 여전히 “외부 신호를 기다리는 중(waiting for external signal)”이라고 표시됩니다. 재시작 후에는 영원히 살아있는 goroutine에 의존하기보다 콜드 복구 경로(cold-recovery path)가 연결합니다.
사람의 승인(Human approval)은 장기 작업에서 가장 극단적인 대기 상태입니다. 몇 시간 또는 며칠이 걸릴 수 있습니다. Baize는 이를 두 가지 경로로 처리합니다:
- 동일 프로세스 핫 패스(Same-process hot path): 이 호스트에서 누군가가 아직 해당 Run을 기다리고 있는 동안, 승인은 lease를 재획득하지 않고 직접 Run을 깨웁니다.
- 교차 재시작 콜드 패스(Cross-restart cold path): 프로세스가 변경되어 인-프로세스 대기자가 사라진 경우; 승인 요청은 “승인 재개(approval resume)” 작업으로 재큐되며, 이 작업이 lease를 획득하고 DB의 일시 중지된 상태에서 계속 진행됩니다.
그룹화된 서브태스크(subtasks)가 활성화되면, 부모(parent) 역시 '외부 신호 대기(waiting for external signal)' 상태로 진입하며 그 이유는 '자식 수신 확인 대기(waiting for a child receipt)'입니다. 자식이 완료되면, '자식 수신 확인(child receipt)' 작업이 부모를 깨웁니다. 자식 실행은 선택적이며 단일 레벨만 가능한 기능으로, 각 부모당 활성 자식의 동시성 제한(현재 구현에서는 최대 2개의 활성 자식 실행 허용)을 가지고 있습니다. 이는 일반적인 서브 에이전트 트리로 무한히 확장되는 것이 아니라, 서로 다른 비즈니스 시스템의 툴 표면(tool surfaces) 및 승인을 격리하는 데 목적이 있습니다.
또한, 휴먼 대기 상태에 진입할 때 워커는 자신의 슬롯을 해제합니다. 더 이상 유휴 상태로 워커 슬롯을 점유하지 않아, 여전히 진행할 수 있는 작업을 위한 동시성을 확보하게 됩니다. 이후 사람이 승인하면 실행은 콜드 리즈음(cold-resumes)됩니다.
모든 작업은 외부에서 패닉 복구(panic recovery)로 감싸져 있습니다: 성공, 실패, 오류 모두 메시지를 확인 응답(ack)하므로, 포이즌 메시지(poison message)가 큐를 막는 일이 없습니다.
5. 프론트엔드 연결 끊김에도 스트림 손실 없음: 번호가 매겨지고 재생 가능한 이벤트
장시간 작업은 스트리밍됩니다: 생각(thoughts), 툴 호출(tool calls), 그리고 점진적인 텍스트가 지속적으로 방출됩니다. 탭을 백그라운드로 전환하거나 네트워크 지터(network jitter)가 발생하면 SSE 연결이 끊어질 수 있습니다.
Baize는 모든 이벤트를 이벤트 테이블에 기록하며, 프론트엔드에 푸시되는 각 프레임에는 단조 증가하는 시퀀스 번호가 포함됩니다. 재연결 시:
- 표준
Last-Event-ID헤더를 선호하며,?after=N도 지원합니다. - 해당 시퀀스 번호부터 누락된 이벤트를 순서대로 재생(replays)합니다.
- 실행이 최종 상태에 도달하면 마지막 종료 프레임(end frame) 하나가 방출됩니다.
복제본(replicas) 간에는 가벼운 이벤트 버스가 깨우는 신호(wake-up nudge)(
6. 스케줄링된 작업(Scheduled tasks): 중복이나 누락 없이 주기적인 트리거 처리
챗 스케줄링 작업(Chat scheduled tasks)은 본질적으로 관리자가 개입하지 않는 장시간 작업 트리거입니다 (일회성 / 매일 / 매주). 각 작업은 상태(활성/일시정지/완료), 다음 실행 시간, 그리고 "현재 진행 중인 실행(run)"을 위한 플레이스홀더 필드와 함께 DB에 영구 저장됩니다.
스케줄 검색(schedule scan)은 대략 15초마다 실행되며, 시작 시에는 비정상적인 종료로 인해 남겨진 오래된 "플레이스홀더 중간 상태"를 먼저 정리하여, 가짜 플레이스홀더가 이후 사이클을 막는 일이 없도록 합니다. 각 라운드(round)는 세 가지 작업을 수행합니다:
- 종료 플레이스홀더 해제: 플레이스홀더가 가리키는 실행이 완료되었다면 슬롯을 해제하여, 장시간 완료된 실행이 미래 사이클을 영원히 막지 않도록 합니다.
- 주기적 시간 전진: 일일/주간 작업의 경우, 이전 라운드가 여전히 실행 중이라 하더라도 다음 시간이 도착하면 "다음 실행(next fire)" 시간을 먼저 전진시켜, 장시간 작업 종료 후 백로그 폭풍을 방지합니다.
- 예정된 작업 원자적 클레임: 조건부 업데이트를 사용하여 "도래했고 유휴 상태인" 작업을 가져온 후 새로운 실행을 시작합니다. 일회성 작업은 완료로 표시되며; 주기적 작업은 다음 시간을 다시 기록합니다.
지원하는 폴백(fallback) 메커니즘 역시 DB에 존재합니다: 실행을 시작하지 못하면 플레이스홀더를 해제하고 오류를 기록하며; 시간은 IANA 시간대를 사용하여 계산됩니다; 각 계정에는 활성 스케줄 제한(기본값 20)이 있습니다. 원자적 플레이스홀더, 종료 시 해제, 그리고 시작 시 정리 작업은 함께 작동하여 동일한 순간에 중복 트리거를 방지하고, 충돌이 나더라도 이후 사이클을 영구적으로 건너뛰는 일이 없도록 보장합니다.
7. 결과 전달(Result delivery): 트랜잭션 아웃박스와 백오프(backoff)
실행이 완료되면 그 결과는 IM 또는 비즈니스 웹훅으로 전송되어야 합니다. 만약 프로세스가 "답변 생성"과 "HTTP 요청 전송" 사이에 충돌하거나, 다운스트림 시스템에서 임시 5xx 오류가 반환되는 경우에도 메시지가 손실되어서는 안 됩니다.
Baize는 채널 아웃바운드(channel outbound)를 위해 **트랜잭션형 아웃박스 (transactional outbox)**를 사용합니다. 메시지를 먼저 비멱등성 전송 키(idempotent delivery key)로 키가 지정된 아웃박스 테이블에 기록한 다음, 별도의 디리버러(deliverer)가 이를 전송합니다. 즉, 생성과 전달이 분리됩니다 (decoupled).
디리버러는 매초(또는 깨어날 때)마다 처리해야 할 메시지 배치(batch)를 가져오며, 배치는 약 20개입니다:
2xx응답 → 전달 완료(delivered)로 표시됩니다.- 네트워크 오류,
429,5xx코드의 경우 → 지수 백오프(exponential backoff)를 적용합니다 (1초, 2초, 4초... 약 60초로 제한). 이후 재시도됩니다. - 재시도할 수 없는 오류(non-retryable errors) 또는 최대 재시도 횟수를 초과한 경우 → 데드 레터(dead letter)로 처리되며, UI에서 수동으로 초기화하여 다시 전송할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기