본문 바로가기
C.W.K.
Stream
Lesson 04 of 04 · published

예산, 교대, 그리고 조용한 아웃박스

~15 min · observability, queues, rate-limiting, context-windows, measurement

Level 0메모 쪽지
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

리뷰어한텐 벽이 필요해

증거로 깨어나고 일을 닫을 수도 있는 치프는 힘이 세. 그러니 자기 판단에 기대지 않는 한계가 있어야 해. 그 한계는 전부 리뷰를 시작할지 말지 정하는 Chief.prepare, 그리고 그걸 감싼 러너 안에 있어.

  • 역할당 활성 실행은 하나. engine_chief_runs의 고유 부분 인덱스가 그 역할에 prepared, running, uncertain 상태인 실행을 딱 하나만 허락해. 하나가 돌고 있으면 prepare는 새로 시작하지 않고 그 실행을 돌려줘.
  • 시간당 예산 12. 지난 한 시간 안에 실행이 열두 번 만들어졌으면 새 실행은 없어. 버려지는 건 없어. 의무는 pending인 채로 보이게 남고, 한 시간 창이 지나가면 다음 틱이 하나를 시작할 수 있어.
  • 실행 하나에 의무는 최대 50개, 제일 오래 밀린 것부터. 피파 쪽 깨움 문도 그보다 긴 목록은 거부해.
  • 언급 안 했으면 15분 뒤, 지금이 아냐. 치프 답이 park 안 된 의무 하나를 건너뛰면 그 의무는 900초(UNMENTIONED_SECONDS) 뒤에 다시 리뷰돼. 깜빡한 답이 무한 루프가 아니라 한정된 후속 확인이 되는 거야. 예외는 park된 의무야. 건너뛰어도 기한이 그대로라서 이미 지난 시각에 머물러. 그래서 바로 다음 실행에 또 올라오고, 그 반복을 막는 건 시간당 예산뿐이야.
  • 실패한 깨움은 15분 뒤 재시도. 턴이 실패하거나, 밀려나거나, 계약을 어긴 결정을 돌려주면 그 실행은 failed가 되고 의무는 900초(FAILED_SECONDS) 뒤에 다시 돌아와. 러너는 예외를 전부 잡고 5초마다 계속 돌아. 러너가 멈추면 서비스는 준비됐다고 말하는데 의무는 하나도 리뷰 안 되는 상태가 되거든.
  • 아빠한텐 한 번만 물어. 같은 바인딩 의무에 대해 park나 escalate를 반복하는데 지난번 이후 새 보고도, 워커 노트도, 메시지도 없으면 기록만 하고 안 보내.

벽 하나는 쓰라리게 배웠어. 2026-09-30 리뷰에서 보니 engine_commands 행 1,288개 중 458개가 prepare 결과였어. 턴이 도는 중이거나 예산이 바닥났을 때도 러너가 5초마다 물었고, 물을 때마다 멱등성 영수증이 하나씩 남았던 거야. 이제 prepare는 평범한 읽기로 판단하고, 실행이 실제로 시작될 때만 명령을 써.

교대: 전체 재생은 턴마다 커져

피파는 턴마다 대화를 처음부터 끝까지 다시 읽어. 사람 채팅엔 괜찮은 성질인데, 하루에 수십 번 깨는 치프한텐 천천히 새는 구멍이야. 운영 사흘째 치프 대화엔 메시지 152개가 쌓였고, 깨울 때마다 그걸 전부 다시 읽는 값을 치렀어. 그래서 러너가 교대를 시켜. 역할의 깨움이 ROTATE_AFTER, 그러니까 40번에 도달했는데 그 틱에서 prepare가 리뷰를 시작하지 않았으면(기한 된 게 없거나, 밀린 의무가 남았는데 시간당 예산이 바닥났거나), 러너는 피파의 bind 문에 새 스레드를 달라고 해. 그리고 같은 브레인, 같은 권한, 같은 정체성으로 역할을 다음 리비전에 묶어. 옛 리비전으로 아직 돌던 실행은 밀려나. 바인더는 같은 컨텍스트 키를 유지하니까 아빠의 Sidekick은 그냥 새 대화를 열어.

교대가 싼 데엔 눈여겨볼 이유가 있어. 치프의 상태는 하나도 대화 안에 살지 않아. pending 의무는 역할이 바뀌어도 살아남고, 깨울 때마다 엔진에서 현재 계약과 기록을 읽고, 처분은 행으로 남아. 대화는 작업대지 기억이 아냐. 반대편에서 본 불변식 5번이야.

아무도 안 읽은 아웃박스

제일 오래 남겨둘 발견은 이거야. Coop은 보낼 메시지를 그걸 일으킨 변경과 같은 트랜잭션에 적어. 내구성 있는 시스템이라면 딱 그래야 하는 방식이야. 결과 보고는 outcome 행을 넣고, blocked 보고나 사라진 세션이나 치프 escalate는 attention 행을 넣어. 세션이 있는 행은 워커 어댑터로 가. 세션이 없는 행은 아빠한테 가는 알림이야.

처음 사흘 동안 그 두 번째 종류는 아무도 안 읽었어. 배정 코디네이터는 invitation이랑 work 행만 소비했고, 나머지는 어디서도 소비하는 프로세스가 없었어. 리뷰가 센 건 영원히 pending인 outcome 행 15개였어. 쓰기는 전부 성공했어. 행도 전부 정확했어. 에러 로그도 없었어. 실패하는 게 없었으니까. 그냥 반대편에 아무도 없었던 거야. 아빠 자리에서 보면 맡긴 일이 소리 없이 끝난 거고.

고치면서 Notifier가 생겼어. 세션 없는 행을 한 줄로 엮어서 피파의 알림 문에 넘기고, 실패하면 한정된 백오프로 미뤄(그 길은 Track 7에서 걸어). 그리고 조심스러운 일 하나를 더 했어. 처음 시작할 때, 알리미가 생기기 전에 쌓인 행은 아빠 채팅에 한꺼번에 쏟아내지 않고 지난 기록으로 만료시켰어.

활동 말고 효과를 재

숫자 셋을 나란히 놓으면 한 이야기야. 깨움 74번, 전부 defer 아니면 escalate, 움직인 의무 0개. 치프는 바빠 보였는데 아무것도 안 바꿨고, 그래서 권한이 넓어졌어. 알림 15개, 완벽하게 쓰였고 한 번도 안 전달됐어. 명령 행 458개, 아무도 답이 필요 없던 질문의 기록. 셋 다 활동 지표(깨움, 쓴 행, 기록된 명령)는 건강했는데, 효과 지표(해결된 의무, 전달된 알림)는 0이었어.

그 대신 아키텍처엔 원하는 계기가 이름까지 적혀 있어. 의무의 나이와 사유, 종결까지 걸린 시간, 전달 재시도, 모르는 결과, 그리고 아빠가 손으로 고친 횟수. 목표치는 지어낸 성공률이 아니라 측정한 기준선에서 정하고.

아무도 안 비우는 큐는 조용한 실패야. 쓰기는 늘 성공하니까 에러가 안 나. 그냥 늙어가. 쓴 행의 개수 말고 종류별로 제일 오래된 pending 행의 나이를 봐. 리뷰어는 얼마나 자주 깨느냐가 아니라 결정이 얼마나 자주 상태를 바꾸느냐로 재.

Code

Coop Chief.prepare 발췌 (잘라냄): 읽기로 판단하고, 쓰기는 실행이 시작될 때만·python
def prepare(self):
    """The review to run now, without writing anything unless a new one starts."""
    with self.engine.connection() as c:
        role=c.execute("SELECT * FROM engine_chief_roles WHERE id='main' AND enabled=1").fetchone()
        if not role:
            return None
        active=c.execute("SELECT * FROM engine_chief_runs WHERE role_id='main' AND status IN ('prepared','running','uncertain')").fetchone()
        if active:
            return dict(active)
        now=time.time()
        if not c.execute("SELECT 1 FROM engine_obligations WHERE status='pending' AND due<=? LIMIT 1",(now,)).fetchone():
            return None
        if c.execute("SELECT count(*) FROM engine_chief_runs WHERE created>?",(now-3600,)).fetchone()[0]>=HOURLY_BUDGET:
            return None
    def run(c):
        ...
        due=[r['id'] for r in c.execute("SELECT id FROM engine_obligations WHERE status='pending' AND due<=? ORDER BY due,id LIMIT 50",(time.time(),))]
        ...
        c.execute("UPDATE engine_chief_roles SET wakes=wakes+1 WHERE id='main'")
        return self.engine.row(c,'engine_chief_runs',rid)
    return self.command(str(uuid4()),'chief.prepare',{},run)
Coop ChiefRunner 발췌 (잘라냄): 교대는 prepare가 리뷰를 시작하지 않은 틱에서만·python
async def rotate(self,role):
    """Start a fresh chief conversation after ROTATE_AFTER wakes.

    Pippa replays a conversation in full on every turn, so a chief
    conversation that only ever grows makes each wake cost more than the
    last (152 messages after three days). The binder keeps the same
    context key, so Dad's Sidekick opens the new conversation.
    """
    ...

async def tick(self):
    review=self.chief.prepare()
    if review is None:
        role=self.chief.context()['role']
        if role and role['enabled'] and role['wakes']>=ROTATE_AFTER and self.client is not None:
            await self.rotate(role)
        return
    ...
최소 스케치: 리뷰의 발견을 잡아냈을 건강 검사 두 개 (그 발견을 본뜬 합성 데이터)·python
import json, sqlite3

db = sqlite3.connect(':memory:')
db.executescript("""
CREATE TABLE outbox (id INTEGER PRIMARY KEY, kind TEXT, session_id TEXT,
                     state TEXT DEFAULT 'pending', created REAL);
CREATE TABLE chief_runs (id INTEGER PRIMARY KEY, decisions TEXT);
""")
now = 3 * 86400.0
# Rows with a receiver get drained; rows for nobody (session_id NULL) pile up.
db.executemany('INSERT INTO outbox(kind, session_id, state, created) VALUES (?,?,?,?)',
               [('work', 's1', 'delivered', 100.0), ('invitation', 's2', 'delivered', 200.0)] +
               [('outcome', None, 'pending', 3600.0 * h) for h in range(1, 16)])
db.executemany('INSERT INTO chief_runs(decisions) VALUES (?)',
               [(json.dumps([{'action': 'defer'}, {'action': 'escalate'}]),)] * 74)

# 1. A queue is healthy when its oldest pending row is young, not when rows exist.
for kind, n, oldest in db.execute(
        "SELECT kind, count(*), min(created) FROM outbox WHERE state='pending' "
        "GROUP BY kind ORDER BY min(created)"):
    print(f'{kind:10} pending={n:3}  oldest={(now - oldest) / 3600:5.1f} h')

# 2. A reviewer is useful when its decisions change work, not when it wakes.
#    defer, escalate and park only reschedule the next look.
EFFECT = {'close', 'release', 'abandon', 'adopt', 'assign', 'retire', 'resend'}
runs = [json.loads(r[0]) for r in db.execute('SELECT decisions FROM chief_runs')]
changed = sum(any(d['action'] in EFFECT for d in run) for run in runs)
print(f'wakes={len(runs)}  wakes that changed anything={changed}')

External links

Exercise

네가 돌리는 시스템의 큐, 아웃박스, 인박스, 'pending' 테이블을 전부 적어봐. 비공식적인 것도. 잡 폴더, 알림 테이블, 메일 임시보관함 라벨 같은 거. 각각에 대해 그걸 비우는 프로세스 이름을 대고, 제일 오래된 pending 항목의 나이를 보여주는 쿼리나 명령 하나를 써. 돌려봐. 그다음 기대고 있는 자동 리뷰어나 봇 하나를 골라서, 최근 실행 쉰 번 중 뭔가를 바꾼 비율을 계산해.
Hint
비우는 쪽 이름을 못 대면 그게 발견이야. 그 큐는 더 돌릴 것도 없어. 리뷰어 쪽은 숫자를 보기 전에 어떤 결과를 효과로 칠지 먼저 정해. 스케치가 일을 닫는 것과 다음 확인을 미루기만 하는 걸 가른 것처럼.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.