본문 바로가기
C.W.K.
Lesson 05 of 06 · published

턴은 재시작을 견뎌

~14 min · asyncio, background-tasks, recovery, fastapi

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

묻는 요청과 기다리는 요청은 따로야

폰이 사진의 실용 번역을 청하면, 엔진은 거의 바로 {"turn": ..., "created": true}로 답해. 번역은 아직 시작도 안 했어. 피파는 몇 초씩 걸려서 줄을 하나씩 흘려보낼 수도 있고, 그 사이 폰은 아빠 주머니 속에서 잠들 수도 있어. 턴이 그 HTTP 요청 안에 살았다면 소켓이 끊기자마자 같이 죽었을 거야.

그래서 턴은 엔진 작업으로 돌아. 턴 모듈의 첫 독스트링이 이걸 못 박아. 턴은 그걸 청한 요청 안에서가 아니라 엔진 작업으로 돌고, 폰이 백그라운드로 가서 소켓을 놓아도 턴은 계속된다고. 첫 번째 코드 블록이 그 장치야. launch는 일을 asyncio 작업으로 감싸고, 턴 id 아래 걸어 두고, 끝나면 치워. 라우트는 답을 돌려주고 빠지고, 작업은 계속 돌아. 작업이 기록하는 결과는 전부 순간의 이벤트 스트림을 타고 폰에 닿아. 그 스트림은 바로 뒤 레슨에서 다뤄.

턴도 코퍼스야

턴은 메모리 속 변수가 아니야. 시작하면 turn.start가 기록되고, 미러는 그 턴을 running 상태로 들고 있어. 피파의 답에서 줄이 하나 파싱될 때마다 result.add로 바로 기록되고, 거기엔 턴 id가 계보로 붙어. 턴을 끝내면 turn.finish가 셋 중 하나의 결말과 함께 기록돼. completed, failed, interrupted. finish_turn은 턴을 한 번만 닫아. 이미 끝난 턴에 또 부르면 그대로 돌려주고 아무것도 안 써.

덕분에 재시작 문제는 디스크만 보고 답할 수 있어. 엔진이 죽었다 깨어나면 자기 미러한테 아직 running인 턴이 뭔지 물으면 돼. 불이 꺼지기 전에 도착한 결과는 이미 전부 갖고 있고. 뒤쪽 절반은 테스트 하나가 붙잡고 있어. 중간에 끊긴 스트림도 도착한 만큼은 남겨.

더 중요한 필드가 하나 있어. 턴마다 자기 id에서 나온 외부 요청 id, spark-<turn id>를 들고 있어. 엔진은 턴을 보낼 때 이걸 cwkPippa한테 같이 넘겨. 다시 켜진 엔진이 나중에 cwkPippa한테 그 턴이 어떻게 됐냐고 물을 수 있는 건 이 공유된 이름 덕분이야.

끌 땐 놓아주고, 켤 땐 정리해

shutdown은 엔진이 따라가던 작업을 전부 취소하고 정리될 때까지 기다려. 정책은 독스트링에 다 있어. 턴 따라가기는 멈추지만 cwkPippa는 계속 답하고, 각 턴이 어떻게 끝났는지는 다시 부팅할 때 복구가 기록한다고.

부팅할 때 엔진의 lifespan은 정해진 순서로 돌아. 코퍼스를 열고(필요하면 미러를 다시 지어), 용도를 심고, 가족 브레인 제어에 등록해. 형제 서비스가 죽어 있으면 등록만 미루고 부팅은 계속돼. 그러고 나서 recover를 불러. 요청을 받기 시작하는 건 그 뒤야. 두 번째 코드 블록이 남은 running 턴을 네 갈래로 나눠.

턴 종류복구가 하는 일이유
live, exchangeinterrupted로 기록, 오류는 engine restarted(엔진이 다시 켜졌다는 뜻)물어볼 cwkPippa 턴이 없는 일회성 호출이야. 재시작에 끊긴 말은 테이블에서 다시 하면 돼
version, review다시 띄워끝낸 구간은 전부 코퍼스에 있으니까 그 뒤 구간부터 이어가
picture다시 띄워Ember 작업 id가 기록돼 있으면 그 작업을 다시 기다려. 그 id를 쓰기 전에 엔진이 죽었으면 다시 띄운 턴이 새 작업을 보내고, Ember가 이미 받았던 작업은 아무도 안 기다려
나머지 전부cwkPippa한테 물어봐(_recover_one)엔진이 꺼진 사이에 턴이 끝났을 수도 있어

이어 가는 종류들은 트랙 7에서 다시 나와. 여기서 볼 건 하나야. 턴 종류마다 복구 방식은 그 턴이 이미 적어 둔 것에서 나와.

cwkPippa한테 결말 묻기

세 번째 코드 블록은 그 외부 요청 id로 cwkPippa한테 턴 상태를 묻고, 답을 딱 세 가지로 나눠 처리해.

  • 모름(404). cwkPippa가 턴을 받은 적이 없어. 엔진이 보내기 전에 죽은 거야. 그 사정을 오류로 적고 interrupted로 기록해.
  • 완료. cwkPippa가 답 전체를 들고 있어. 엔진은 그걸 파싱해서 이 턴이 아직 기록하지 않은 결과만 기록해. 줄은 Vision 번호로, 나머지는 텍스트로 맞춰 봐. 그리고 recovered: true와 그 턴이 돈 대화를 붙여서 completed로 끝내.
  • 그 밖의 답. 아직 도는 중이거나 cwkPippa가 잠깐 안 닿는 경우야. 5초 기다렸다 다시 묻고, 그 기다림이 합쳐서 600초가 될 때까지 그래. 그래도 안 끝나면 마지막에 들은 상태를 적고 interrupted로 기록해. 세는 건 잠든 시간뿐이라, 상태 요청 하나하나에 걸린 시간은 그 위에 얹혀. cwkPippa가 느리게 답하면 실제로 기다린 시간은 10분을 넘겨.

절대 안 하는 게 하나 있어. 턴을 다시 보내는 거. 아키텍처 문서는 이걸 규칙으로 적어 둬. 재시작 뒤 엔진은 먼저 cwkPippa한테 턴 상태를 묻고, 결말을 모르는 턴은 다시 보내지 않는다고. 다시 보내면 같은 스레드에 답이 두 번 달리고, 결과도 두 벌이 되고, 비용도 두 번 나갈 수 있어. 이미 성공했을지도 모르는 턴 하나를 살리겠다고 말이야.

테스트

테스트 하나가 어려운 경우를 손으로 꾸며. 코퍼스를 열고 턴 셋을 시작하고는, 하나도 끝내지 않고 닫아. 죽기 전에 이미 줄 하나를 기록한 실용 턴, 또 다른 실용 턴, 그리고 라이브 턴이야. 가짜 cwkPippa는 첫 번째는 답 전체와 함께 완료됐다고 하고, 두 번째는 들어 본 적도 없다고 해. 부팅 뒤 첫 번째는 completed에 recovered이고 결과는 셋이야. 죽기 전에 기록한 줄은 한 번만 나와. 두 번째랑 라이브 턴은 interrupted야.

두 번째 테스트는 시계를 줄여. 묻는 간격을 0.01초로, 기다리는 한도를 0.03초로 바꾸고, pending이라고 답하는 턴을 남겨. 그리고 그 턴이 오류에 pending을 담은 채 interrupted로 끝나는지 확인해. 10분어치 잠을 몇백 분의 1초 만에 검사하는 거야.

원칙: 긴 작업엔 상대도 아는 이름을 붙이고, 진행은 일어나는 대로 적어 둬. 그러면 재시작은 물어보면 되는 질문이 돼.

Code

cwkSpark, backend/turns.py: 엔진이 따라가는 작업이 된 턴, 그리고 루프 밖으로 뺀 쓰기 (원문 그대로, 736-757행)·python
    def launch(self, key: str, work: Awaitable[None]) -> None:
        task = asyncio.ensure_future(work)
        self._tasks[key] = task
        task.add_done_callback(lambda _done: self._tasks.pop(key, None))

    async def wait_idle(self) -> None:
        """Every launched turn has ended (tests and a graceful stop)."""
        while self._tasks:
            await asyncio.gather(*list(self._tasks.values()), return_exceptions=True)

    async def shutdown(self) -> None:
        """Stop following turns. cwkPippa keeps answering; the next boot's
        recovery records how each one ended."""
        for task in list(self._tasks.values()):
            task.cancel()
        await asyncio.gather(*list(self._tasks.values()), return_exceptions=True)

    # ── helpers ─────────────────────────────────────────────────────

    async def _write(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
        # Corpus writes fsync and take a threading lock: keep them off the loop.
        return await asyncio.to_thread(fn, *args, **kwargs)
cwkSpark, backend/turns.py: 남은 턴을 종류별로 (원문 그대로, 1391-1415행)·python
    async def recover(self) -> None:
        """Settle the turns a previous engine process left running."""
        for turn in await asyncio.to_thread(self.corpus.running_turns):
            if turn["id"] in self._tasks:
                continue
            if turn.get("kind") in ("live", "exchange"):
                # One-shot calls with no cwkPippa turn to ask after: an
                # utterance cut by a restart is said again at the table.
                await self._write(
                    self.corpus.finish_turn, turn["id"], "interrupted", error="engine restarted"
                )
                continue
            # Versions and reviews ride stateless doors; every section they
            # finished is in the corpus, so they resume at the next one.
            if turn.get("kind") == "version":
                self.launch(turn["id"], self.run_version(turn))
                continue
            if turn.get("kind") == "review":
                self.launch(turn["id"], self.run_review(turn))
                continue
            # A picture whose Ember job was recorded goes back to waiting on it.
            if turn.get("kind") == "picture":
                self.launch(turn["id"], self.run_picture(turn))
                continue
            self.launch(turn["id"], self._recover_one(turn))
cwkSpark, backend/turns.py: cwkPippa한테 묻고, 다시 보내지 않아 (원문 그대로, 1417-1466행)·python
    async def _recover_one(self, turn: dict[str, Any]) -> None:
        waited = 0.0
        while True:
            try:
                status = await self.pippa.turn_status(turn["external_request_id"])
            except PippaError as exc:
                status = {"status": "unreachable", "error": str(exc)}
            state = (status or {}).get("status")
            if status is None:
                await self._write(
                    self.corpus.finish_turn,
                    turn["id"],
                    "interrupted",
                    error="the engine restarted before cwkPippa received the turn",
                )
                return
            if state == "completed":
                moment = await asyncio.to_thread(
                    self.corpus.moment, turn["moment_id"], include_deleted=True
                )
                if moment is None:
                    await self._fail(turn, "the moment is gone")
                    return
                recorded = {
                    result_key(item): item
                    for item in moment.get("results") or []
                    if item.get("turn_id") == turn["id"]
                }
                objects, prose = parse_reply(str(status.get("result") or ""))
                await self._record_results(turn, moment, objects, recorded)
                await self._write(
                    self.corpus.finish_turn,
                    turn["id"],
                    "completed",
                    prose=prose,
                    results=len(recorded),
                    recovered=True,
                    conversation_id=status.get("conversation_id"),
                )
                return
            if waited >= RECOVERY_WAIT_SECONDS:
                await self._write(
                    self.corpus.finish_turn,
                    turn["id"],
                    "interrupted",
                    error=f"cwkPippa still reports the turn as {state or 'unknown'}",
                )
                return
            await asyncio.sleep(RECOVERY_POLL_SECONDS)
            waited += RECOVERY_POLL_SECONDS

External links

Exercise

직접 만드는 웹 서비스에서 느린 작업 하나를 요청 밖으로 빼 봐. 라우트는 작업을 running으로 기록하고, 백그라운드 작업을 띄우고, 작업을 바로 돌려줘. 작업엔 자기 id에서 나온 외부 id를 붙여서 실제로 느린 일을 하는 쪽에 넘겨. 이제 복구를 짜. 시작할 때 아직 running인 작업을 찾고, 외부 id로 일꾼한테 물어서 하나씩 정리해. 답 세 가지(받은 적 없음, 끝남, 아직 도는 중)를 다 다뤄야 해. 작업을 몇 개 띄우고, 끝나기 전에 서비스를 멈추고, 다시 켜서 테스트해.
Hint
작업 객체는 작업 id를 키로 하는 사전에 넣고, 완료 콜백에서 빼. 복구가 이미 따라가는 작업을 건너뛸 수 있게 해 주고, 강한 참조라서 이벤트 루프가 작업을 놓쳐 버리지도 않아. 부분 출력은 도착하는 대로 기록하고, 끝났다는 답이 오면 빠진 것만 기록해.

Progress

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

댓글 0

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

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