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

같은 요청이 두 번 오면

~14 min · 멱등성, 트랜잭션, sqlite, 재시도

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

끝내 안 온 답

생성 요청 하나가 꽤 클 수 있어. 아키텍처 문서에 나오는 예가 이래. 15프레임짜리 컬렉션 둘에 공급자 둘, 후보를 다섯씩 뽑으면 세트마다 요청 하나씩, 150개짜리 요청 둘로 태스크가 300개야. 이제 답이 중간에 사라졌다고 쳐 봐. 업로드가 느린 폰일 수도 있고, 타임아웃 난 프록시일 수도 있고, 두뇌의 도구 호출을 다시 보내는 하네스일 수도 있어. 클라이언트는 요청이 아예 안 닿은 건지, 닿았는데 답만 사라진 건지 구분 못 해. 다시 보낼 때마다 배치가 하나 더 생긴다면, 네트워크가 한 번 삐끗할 때마다 청구서도 한 장 더 오는 거야.

요청 이름은 부르는 쪽이 지어

생성 요청엔 전부 request_id가 붙어. 1자에서 100자 사이고, 부르는 쪽이 정해. 서버는 절대 대신 만들지 않아. 두 번의 POST가 같은 의도인지는 부르는 쪽만 알거든. 웹 스튜디오는 아빠가 리뷰 단계를 열 때 crypto.randomUUID()로 하나 만들고, 요청 본문 전체를 그 미리보기에 얼려 둬. 제출 버튼은 그 얼린 요청을 보내. 답이 사라져도 미리보기는 열린 채라서, 제출을 다시 누르면 같은 id가 다시 가.

id 옆에 지문을 둬

id만으론 모자라. 클라이언트 버그로 id를 다른 요청에 또 쓸 수도 있잖아. 그러면 서버는 엉뚱한 배치를 태연하게 돌려줄 거야. 그래서 requests 테이블은 id마다 지문을 같이 적어. 페이로드를 키 정렬한 JSON으로 만든 다음 뽑은 SHA-256이야. 키를 정렬하니까 필드 순서가 바뀌어도 해시는 그대로야. 결과는 셋 중 하나야.

  • 처음 보는 id: 배치를 계획하고 만들어.
  • 아는 id, 같은 지문: 처음에 만든 레코드를 돌려줘. 계획도 새로 안 세우고 태스크도 안 늘려.
  • 아는 id, 다른 지문: 409야. The request ID already belongs to different input. 그 요청 id는 이미 다른 입력의 것이라는 말이지.

리믹스 테스트가 셋을 다 돌려 봐. 프레임 두 개짜리 세트에 공급자 둘, 후보 셋씩으로 요청을 넣고 태스크 12개를 세. 같은 요청을 한 번 더 넣으면 같은 배치 id가 돌아와. 끝으로 같은 id에서 프롬프트만 바꾸고 Conflict가 나는지 확인해.

트리 전체를 트랜잭션 하나로

배치는 부모 레코드 하나에 이미지마다 자식 태스크가 하나씩 달린 모양이야. create_intent는 이걸 한 번에 써. docstring도 한 줄로 말해. 멱등 키와 의도, 태스크 트리 전체를 같이 커밋한다. 먼저 BEGIN IMMEDIATE로 열고, 그 쓰기 트랜잭션 안에서 요청 id를 확인해. 그리고 배치를 넣고, 배치를 부모로 태스크를 전부 넣고, 요청 바인딩을 넣고, 커밋은 딱 한 번 해.

다른 방법을 떠올려 봐. 배치를 커밋하고 태스크를 따로 커밋하면, 그 사이에 죽었을 때 태스크가 반만 달린 배치가 남아. 워커는 계획의 반쪽만 돌리겠지. 태스크를 커밋하고 바인딩을 따로 커밋하면, 완성된 배치를 재시도가 못 찾아. 그러면 재시도가 배치를 하나 더 만들어. 트랜잭션 하나면 두 틈이 다 막혀. 계획 하나엔 태스크가 기본값으로 최대 600개(AVATARS_MAX_BATCH_TASKS) 들어가고, 전부 같이 커밋되거나 하나도 안 돼.

BEGIN IMMEDIATE는 확인하기 전에 쓰기 잠금부터 잡아. 그래서 같은 요청 id를 든 스레드 둘이 둘 다 아직 없다고 보고 둘 다 넣는 일은 없어. 두 번째 스레드는 잠금을 기다렸다가, 첫 번째가 남긴 바인딩을 보고 그 배치를 돌려줘.

롤백을 증명하는 테스트

한 줄씩 읽어 볼 만한 테스트가 하나 있어. create_intent에 태스크 둘짜리 배치를 시키는데, 두 번째 태스크 body에 파이썬 맨 object()를 넣어. json.dumps가 직렬화 못 하는 값이야. 그 TypeError가 터질 때쯤엔 배치 행이랑 첫 태스크가 이미 들어가 있어. 테스트는 그 뒤에 배치도, 태스크도, 그 요청 id의 바인딩도 하나도 안 남았는지 확인해. 멀쩡한 페이로드로 다시 보내면 빈손에서 시작하는 거야.

네트워크는 바깥, 트랜잭션은 안쪽

배치를 계획하려면 피파와 Ember한테 물어봐야 해. 피파는 부르는 쪽이 어떤 등장 대상을 볼 수 있는지 정하고, Ember는 생성기 목록을 알고 자동 경로를 풀어 줘. 이 중 어느 것도 SQLite 트랜잭션 안에서 일어나면 안 돼. 그래서 generate는 스튜디오의 asyncio 잠금인 intent_lock 아래에서 세 단계로 움직여. 먼저 평범한 읽기로 요청 id를 확인하고, 네트워크 너머로 계획을 세우고, 마지막에 create_intent를 불러. create_intent는 쓰기 전에 자기 트랜잭션 안에서 한 번 더 확인해. 앞쪽 확인은 재시도가 계획을 또 세우는 수고를 덜어 줘. 보장은 뒤쪽 확인이 해.

원칙: 키는 의도를 기록하는 바로 그 커밋에서 의도와 묶어. 따로 커밋한 건 전부 재시도가 빠질 수 있는 틈이야.

스튜디오 다른 곳에서도 같은 질문

  • 발행도 create_intent를 거쳐. 그래서 답을 잃은 발행은 같은 발행을 이어서 끝내. 이 얘긴 트랙 7에서 해.
  • 사진 업로드엔 client_key가 붙을 수 있어. 업로드를 저장하려면 파일부터 써야 하는데, 파일은 SQLite 트랜잭션에 못 들어가. 그래서 이 경로는 스토어의 작은 호출 둘을 써. 요청을 찾아보고, 에셋을 저장하고, 요청을 그 에셋에 묶는 일을 전부 업로드 잠금 아래에서 해. 폰에 이게 왜 필요한지는 트랙 9에서 다뤄.
  • 워커도 자기 단계에 같은 질문을 던져. finish는 끝난 이미지를 Ember에서 가져오기 전에 이 태스크 id가 붙은 에셋이 이미 있는지 보고, 있으면 그걸 써. 그래서 이미지를 저장하고 나서 죽은 워커도, 다시 살아나서 같은 이미지를 두 번 저장하지 않아.

Code

cwkAvatars, backend/store.py: create_intent, 키와 의도와 태스크 트리를 커밋 한 번에 (원문 그대로, 124-140행)·python
    def create_intent(self, request_id: str, payload: dict, kind: str, body: dict, *, parent="", children=()) -> dict:
        """Commit the idempotency key, intent and complete task tree together."""
        fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
        record_id, at = identity(), now()
        with self.tx() as db:
            db.execute("BEGIN IMMEDIATE")
            existing = db.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
            if existing:
                if existing["fingerprint"] != fingerprint:
                    raise Conflict("The request ID already belongs to different input.")
                return self.unpack(db.execute("SELECT * FROM records WHERE id=?", (existing["record_id"],)).fetchone())
            entries = [(record_id, kind, parent, body)]
            entries.extend((identity(), child_kind, record_id, child_body) for child_kind, child_body in children)
            for id, entry_kind, entry_parent, entry_body in entries:
                db.execute("INSERT INTO records(id,kind,parent,body,created_at,updated_at) VALUES(?,?,?,?,?,?)", (id, entry_kind, entry_parent, json.dumps(entry_body), at, at))
            db.execute("INSERT INTO requests VALUES(?,?,?)", (request_id, fingerprint, record_id))
        return self.get(record_id)
cwkAvatars, backend/tests/test_studio.py: 세 번째 삽입에서 실패하면 아무것도 안 남아 (원문 그대로, 204-209행)·python
def test_intent_tree_rolls_back_everything_on_partial_insert_failure(tmp_path):
    store = Store(tmp_path)
    with pytest.raises(TypeError):
        store.create_intent("intent", {"prompt": "x"}, "batch", {}, children=[("task", {"good": True}), ("task", {"bad": object()})])
    assert store.list("batch") == store.list("task") == []
    assert store.request("intent", {"prompt": "x"}) is None
cwkAvatars, backend/studio.py: generate는 먼저 확인하고, 트랜잭션 바깥에서 네트워크로 계획을 세워 (원문 그대로, 548-554행)·python
    async def generate(self, body, actor=None):
        payload = {**body.model_dump(), "actor_soul_id": actor}
        async with self.intent_lock:
            existing = await self.io("request", body.request_id, payload)
            if existing:
                return existing
            plan = await self.plan(body, actor)

External links

Exercise

네 앱에서 자식이 딸린 부모 레코드를 만드는 엔드포인트를 하나 골라. 품목이 붙은 주문이든, 단계가 붙은 작업이든. 부르는 쪽이 정하는 요청 id를 받고, 키 정렬한 JSON 페이로드의 SHA-256 지문을 적는 requests 테이블을 더해. 모든 행과 바인딩을 BEGIN IMMEDIATE 트랜잭션 하나에서 써. 테스트는 셋이야. 같은 id와 페이로드를 두 번 보내면 부모는 하나만 생겨. 같은 id에서 필드 하나만 바꾸면 거절돼. 마지막 자식에서 직렬화가 실패하면 부모도, 자식도, 바인딩도 안 남아.
Hint
파이썬 sqlite3에선 연결의 컨텍스트 매니저 안에서 "BEGIN IMMEDIATE"를 직접 실행해. 컨텍스트 매니저가 성공하면 커밋하고, 예외가 나면 롤백해. 삽입을 중간에 실패시키는 제일 싼 방법은 자식 body 하나에 직렬화 안 되는 object()를 넣는 거야.

Progress

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

댓글 0

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

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