화면 둘에 세트 하나
아빠는 어디에 있든 후보를 리뷰해. Mac의 웹 스튜디오랑 iPhone, iPad 앱은 같은 WebUI야. 앱은 그걸 WebKit 안에 띄우고 같은 엔진이랑 얘기해. 아키텍처 문서도 분명히 적어 뒀어. 선택은 서버 거고, 낙관적 리비전이 붙고, 웹과 iOS가 저장된 선택을 같이 쓴다고. 그러니까 이런 경쟁 상황을 떠올려 봐. 배치 리뷰가 두 화면에 다 열려 있어. 아빠가 iPad에서 후보 하나를 골라. 조금 뒤에 한 시간째 페이지가 열려 있던 Mac으로 가서, 거기서 다른 후보를 골라.
쓸 때마다 움직이는 숫자
records의 모든 행엔 1부터 시작하는 version 컬럼이 있어. update는 새 body를 쓰는 바로 그 문장에서 이 숫자를 하나 올려. 클라이언트는 레코드를 읽을 때 버전도 같이 읽고, 바꾼 걸 보낼 때 그 숫자를 돌려보내. Store.update는 둘을 비교해. 저장된 버전이 클라이언트가 읽은 게 아니면, 그 사이에 누가 썼다는 뜻이야. 스토어는 409로 거절해. This record changed. Refresh before saving again. 레코드가 바뀌었으니 새로고침한 다음에 다시 저장하라는 말이야.
이게 낙관적 동시성이야. 아빠가 화면을 보는 동안엔 한 시간이 걸려도 잠금을 하나도 안 쥐어. 경쟁에서 지면 거절 한 번이 비용이고, 그 비용은 진 쓰기만 치러.
BEGIN IMMEDIATE가 중요한 이유
비교와 쓰기는 한 걸음이어야 해. Studio.io는 스토어 호출을 전부 asyncio.to_thread로 넘기니까, 선택 두 개가 스레드 두 개에 실려 같은 순간 스토어에 닿을 수 있어. update는 BEGIN IMMEDIATE로 시작해. 뭘 읽기도 전에 SQLite 쓰기 잠금부터 잡는 거야. 두 번째 스레드는 연결의 10초 타임아웃 안에서 기다렸다가, 첫 번째가 막 쓴 버전을 읽고 비교에서 떨어져.
이 줄을 빼면 가드에 구멍이 나. 파이썬 sqlite3 모듈은 기본 모드에서 쓰는 문장 앞에서만 트랜잭션을 열고, SELECT 앞에선 안 열어. 두 스레드가 다 버전 7을 읽고, 업데이트 둘이 다 들어갈 수 있어. UPDATE 자체는 id로만 행을 찾으니까.
고칠 수 있는 레코드엔 전부 버전이 붙어
| 아빠가 고치는 것 | 라우트 | 버전 |
|---|---|---|
| 세트의 선택 | PATCH /api/sets/{id}/selection | 필수 |
| 에셋 이름이나 보관 표시 | PATCH /api/assets/{id} | 필수 |
| 세트나 배치의 보관 표시 | PATCH /api/sets/{id}, PATCH /api/batches/{id} | 필수 |
| 레퍼런스 프로필, 워크플로 템플릿 | PUT /api/souls/{id}/references, PUT /api/templates/{workflow} | 처음 저장할 때만 생략 |
마지막 줄은 경쟁 상황을 하나 더 막아. 두 화면이 같은 소울의 첫 프로필을 동시에 만드는 경우야. 저장은 스튜디오의 intent 잠금을 잡고 이미 있는 프로필을 찾아. 버전 없는 요청은 아직 아무것도 저장 안 됐다는 주장이야. 그런데 뭔가 저장돼 있으면 그 주장이 틀린 거니까 Reference settings changed. Refresh first.로 거절해. 레퍼런스 설정이 바뀌었으니 먼저 새로고침하라는 말이야. 아무것도 없는데 버전을 들고 오면 그것도 똑같이 틀린 거라 같은 거절을 받아. 템플릿도 같은 규칙이야.
버전 없이 쓰는 경우도 있어. 워커가 자기 태스크 상태를 옮길 때, 그리고 발행이 끝날 때야. 두 번째는 열려 있는 모든 화면에 닿아. 발행이 끝나면 세트의 새 발행 맵과 라이브 리비전을 쓰고, 그때 세트 버전이 올라가. 그래서 옛 숫자를 쥔 화면은 다음 선택에서 409를 받고 다시 불러와.
서버는 자기 병합도 지켜
select는 세트를 읽고, 고른 후보를 하나씩 확인해. 맞는 소울인지, 생성된 후보라면 이 세트의 그 프레임에 끝난 태스크가 있는지. 그리고 새 선택을 기존 선택에 합쳐서 expected=body.version으로 써. 읽기와 쓰기 사이엔 피파 레지스트리로 가는 호출이 끼어 있어. 그 틈에 다른 쓰기가 들어오면 버전이 움직였으니, 옛 읽기 위에 쌓은 병합은 거절돼. 숫자 하나가 클라이언트가 본 화면도, 서버 자신이 읽고 합치고 쓰는 과정도 같이 지켜.
409를 받은 화면이 하는 일
Batches 페이지는 자기가 쥔 세트 버전을 보내. 호출이 실패하면 에러를 보여 주고, 메시지에 레코드가 바뀌었다는 말이 있으면 세트를 다시 불러와. 그래야 다음 클릭에 새 버전이 실려. 선택을 다시 보내진 않아. 난 이게 맞다고 봐. iPad에서 고른 게 아빠가 원한 것일 수도 있잖아. 덮어쓰기 전에 그걸 먼저 봐야지.
솔직한 약점도 하나 있어. 페이지는 충돌을 409 상태 코드가 아니라 메시지 속 changed라는 단어로 알아봐. 지금 이 경로의 충돌 메시지엔 그 단어가 들어 있어. 서버가 문장을 바꾸면 다시 불러오기가 소리 없이 멈출 거야. 상태 코드로 확인했다면 상관없었을 텐데.
선택이 뭘 뜻하는지, 왜 고르는 게 발행이 아닌지는 트랙 7의 첫 레슨에서 다뤄.