끝내 안 온 답
생성 요청 하나가 꽤 클 수 있어. 아키텍처 문서에 나오는 예가 이래. 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가 붙은 에셋이 이미 있는지 보고, 있으면 그걸 써. 그래서 이미지를 저장하고 나서 죽은 워커도, 다시 살아나서 같은 이미지를 두 번 저장하지 않아.