피파는 프레임을 확장자 없이 이름으로 내줘. /avatars/{who}/{preset}/{emotion} 라우트는 정해진 목록 .jpeg, .jpg, .png, .webp를 차례로 훑고, 처음 있는 파일을 내줘. 소울 라우트도 소울 루트와 그 default 폴더에서 똑같이 해. 이제 제일 뻔한 발행 방법을 떠올려 봐. 새 default.png를 라이브 폴더에 복사하는 거. 폴더에 옛 default.jpg가 아직 있으면 피파는 계속 옛 얼굴을 내줘. 실패도 없고 로그도 없고, 발행은 적용된 것처럼 보여. 아키텍처는 이 함정을 한 줄로 짚어. 옛 JPEG가 새로 설치한 PNG를 가리면 안 된다고.
답은 라이브 폴더에 절대 섞어 넣지 않는 거야. 피파는 완성된 새 폴더를 다른 데서 만들고, 옛 폴더를 통째로 갈아 끼워. 리비전 함수도 서버랑 같은 편이야. frame_files는 확장자를 거꾸로 훑어. 그래서 두 파일이 같은 프레임 이름을 쓰면, 라우트가 내줄 그 파일이 해시에 들어가.
건드리기 전에 다 확인해
publish는 검사 순서를 이렇게 짜. 전부 통과하기 전엔 라이브 폴더에 손이 안 닿게.
요청 id 모양이 안전한지 봐. 아는 id에 지문까지 같으면 곧장 그 영수증으로 가.
대상을 레지스트리에서 풀어. 올바른 채널, 슬러그 이름, 아바타 루트가 있는 활성 주체여야 해.
라이브 리비전이 예상한 값과 같은지 봐.
세트가 갖출 건 갖췄는지 봐. 프레임이 하나 이상이고, 그중에 default가 있어야 해.
원본 경로가 전부 Avatars 원본 디렉터리 안으로 풀리는지, 이미지 파일인지, 적어 둔 SHA-256과 아직 같은지 봐.
주체가 전부 지금 레지스트리에서 활성인지 보고, 공개 소울이 보호 주체를 받지 못하게 해. 이 규칙은 트랙 8 '보호는 픽셀을 따라가'에서 다뤄.
어느 단계에서 거절되든 피파의 아바타 폴더는 그대로야. 보호를 다루는 피파 테스트가 딱 그걸 확인해. 거절된 발행은 예외를 내고, 옛 파일은 옛 바이트를 그대로 쥐고 있어.
백업부터, 그리고 백업도 확인해
백업은 avatar-backups/studio/<request_id>/previous로 가. 피파의 아바타 폴더 옆에 있지만, 아바타를 내주는 라우트 밖이야. 이미 있으면 다시 복사하지 않아. 그래서 재시도가 첫 백업을 더 새로운 걸로 덮어쓸 일이 없어. 그리고 백업의 리비전이 라이브 리비전과 같아야 해. 다르면 Backup verification failed, 백업 검증 실패로 멈춰. 아무도 확인 안 한 백업은 그냥 바람이야.
디렉터리를 통째로 새로 준비해
준비 폴더는 대상과 같은 부모 아래 새로 만든 .avatars-stage-<uuid>야. 프레임마다 프레임 이름에 원본 확장자를 붙여 복사하고, 다시 해시해. 오는 길에 바이트 하나라도 바뀌었으면 Staged original verification failed, 준비한 원본 검증 실패야. 옛 폴더 구조 하나는 조심해야 해. 기본 프레임이 소울 루트에 바로 놓인 소울은 다른 컬렉션을 거기 하위 폴더로 두거든. 그래서 그 구조에선 준비 폴더가 먼저 하위 폴더 전부와 이미지 아닌 파일 전부를 받고, 옛 이미지는 하나도 안 받아. 혈통 파일은 맨 마지막에 준비 폴더에 써. 이어서 영수증에 prepared 표시를 달고, 바꾸기가 건드릴 경로를 전부 적어 둬.
이게 안전한 이유는 두 가지야. 첫째, 준비 폴더가 대상과 같은 부모 아래 있으니까 설치하는 이름 바꾸기가 파일 시스템 하나 안에서 끝나. 그 조건에선 os.replace가 원자적이라고 파이썬 문서에 적혀 있어. 둘째, 점으로 시작하는 이름은 이 모듈이 프레임과 컬렉션에 쓰는 슬러그 패턴에 절대 안 맞아. 패턴은 글자나 숫자로 시작해야 하거든. 그래서 이 모듈의 탐색은 준비 폴더나 밀려난 폴더를 컬렉션으로 착각하지 않아.
이름 바꾸기 두 번과 영수증 하나
os.replace는 파일이 든 디렉터리를 갈아 끼우지 못해. 그래서 바꾸기는 이름 바꾸기 두 번이야. 라이브 폴더를 .avatars-old-<uuid>로 비켜 두고, 준비 폴더를 그 자리로 옮겨. 그러고 나면 설치된 리비전을 영수증과 맞춰 봐. 안 맞으면 Installed image verification failed, 설치한 이미지 검증 실패야. 맞으면 영수증 두 군데에 적용됨을 적고, 밀려난 폴더를 지워. 검증된 이전 트리는 이미 백업에 있으니까.
두 이름 바꾸기 사이 그 찰나엔 라이브 폴더가 없어. 딱 그때 들어온 요청은 404를 받을 거야. 설계는 이 틈을 숨기지 않아. 대신 그 안에서 프로세스가 죽어도 전부 되살릴 수 있게 해. finish_publication은 디스크를 보고 멈춘 자리에서 이어 가.
프로세스가 죽은 때
디스크 상태
같은 요청을 다시 보내면
prepared 영수증 뒤, 이름 바꾸기 전
옛 라이브 폴더, 완성된 준비 폴더
옛 걸 비켜 두고, 준비 폴더를 옮기고, 검증해
두 이름 바꾸기 사이
라이브 폴더는 없고, 준비 폴더와 밀려난 폴더가 있어
준비 폴더를 옮기고, 검증해
두 번 다 바꾼 뒤, 영수증에 적용됨을 적기 전
새 라이브 폴더, 밀려난 폴더
새 리비전을 보고, 검증하고, 적용됨을 적어
그런데 준비 폴더가 없어졌거나 망가졌으면
쓸 만한 준비 폴더가 없어
라이브 폴더가 없으면 밀려난 폴더를 되돌려 놓고, 이전 세트는 지켰다는 말과 함께 거절해
그 함수 맨 위엔 가드가 하나 더 있어. 라이브 폴더의 리비전이 옛 것도 새 것도 아니면, 그 사이에 누군가 발행한 거야. 그러면 재개는 그걸 덮어쓰지 않고 The interrupted publication conflicts with a newer live set로 거절해. 끊긴 발행이 더 새로운 라이브 세트와 부딪친다는 말이야. 피파 테스트는 옛 폴더를 비켜 둔 바로 뒤에 프로세스를 죽여 봐. 라이브 폴더가 없는 걸 확인하고, 같은 발행을 다시 돌려서 적용된 영수증을 받아 내.
원칙: 바꾸기를 원자적으로 못 만들면, 계획부터 적어 두고 모든 단계를 되풀이해도 안전하게 만들어. 그러면 중간에 죽는 일도 잠깐 멈춘 게 돼.
Code
cwkPippa, backend/services/avatar_studio.py: publish, 세트를 통째로 준비하고, 다시 해시하고, 혈통과 prepared 영수증을 써 (원문 그대로, 253-276행)·python
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".avatars-stage-{uuid4().hex}"
staging.mkdir()
# Legacy guest defaults live directly in their soul root. Preserve
# unrelated presets/On Air directories when activating that layout.
if target == avatar_root(entry) and target.exists():
for child in target.iterdir():
if child.is_dir():
shutil.copytree(child, staging / child.name)
elif child.suffix.lower() not in EXTENSIONS and child.name != "avatar-lineage.json":
shutil.copy2(child, staging / child.name)
for frame, source in checked.items():
shutil.copy2(source, staging / f"{frame}{source.suffix.lower()}")
if digest(staging / f"{frame}{source.suffix.lower()}") != files[frame]["sha256"]:
raise ValueError("Staged original verification failed")
expected = revision(frame_files(staging))
# Provenance travels with the set for public export policy, but is
# deliberately not an image and never part of frame discovery.
manifest["revision"] = expected
atomic_write_text(staging / "avatar-lineage.json", json.dumps(manifest, indent=2))
displaced = target.parent / f".avatars-old-{uuid4().hex}"
receipt = {"request_id": request_id, "fingerprint": fingerprint, "status": "prepared", "soul_pk": body["soul_pk"], "channel": body["channel"], "name": body["name"], "previous_revision": previous, "revision": expected, "backup": str(backup), "target": str(target), "staging": str(staging), "displaced": str(displaced)}
atomic_write_text(receipt_path, json.dumps(receipt, indent=2))
return finish_publication(receipt, receipt_path)
cwkPippa, backend/services/avatar_studio.py: finish_publication, 어디서 죽어도 이어지는 이름 바꾸기 두 번 (원문 그대로, 165-195행)·python
def finish_publication(receipt: dict, receipt_path: Path) -> dict:
"""Resume a prepared activation after any crash between directory renames."""
if receipt.get("mode") == "adopt":
target, backup = Path(receipt["target"]), Path(receipt["backup"])
if revision(frame_files(target)) != receipt["revision"]:
raise ValueError("The legacy copies changed before adoption completed")
atomic_write_text(target / "avatar-lineage.json", json.dumps(receipt["manifest"], indent=2))
receipt["status"] = "applied"
atomic_write_text(receipt_path, json.dumps(receipt, indent=2))
atomic_write_text(backup / "receipt.json", json.dumps(receipt, indent=2))
return receipt
target, staging, displaced, backup = (Path(receipt[key]) for key in ("target", "staging", "displaced", "backup"))
current = revision(frame_files(target)) if target.exists() else None
if current != receipt["revision"]:
if current is not None and current != receipt["previous_revision"]:
raise ValueError("The interrupted publication conflicts with a newer live set")
if not staging.exists() or revision(frame_files(staging)) != receipt["revision"]:
if not target.exists() and displaced.exists():
os.replace(displaced, target)
raise ValueError("The prepared set is unavailable; the previous set was preserved")
if target.exists():
os.replace(target, displaced)
os.replace(staging, target)
if revision(frame_files(target)) != receipt["revision"]:
raise ValueError("Installed image verification failed")
receipt["status"] = "applied"
atomic_write_text(receipt_path, json.dumps(receipt, indent=2))
atomic_write_text(backup / "receipt.json", json.dumps(receipt, indent=2))
if displaced.exists():
shutil.rmtree(displaced) # the verified previous tree remains in backup
return receipt
cwkPippa, backend/tests/test_avatar_studio.py: 옛 JPEG는 사라지고, 그 바이트는 백업에 살고, 되풀이하면 같은 영수증이 와 (원문 그대로, 66-79행)·python
네 앱이 내주는 파일 디렉터리를 설치하는 함수를 써 봐. 순서는 이래. 먼저 입력을 검증해. 라이브 디렉터리를 백업하고 백업 해시를 확인해. 대상 옆에 완성된 준비 폴더를 만들고, 경로를 전부 적은 영수증을 써. 마지막으로 이름 바꾸기 두 번으로 갈아 끼우고 검증해. 첫 이름 바꾸기 뒤에 예외를 던지는 테스트 훅을 넣어. 설치를 돌려서 죽게 하고, 같은 요청 id로 다시 돌린 다음, 라이브 디렉터리가 완성돼 있고 백업엔 옛 게 그대로 있는지 확인해.
Hint
준비 폴더는 대상의 부모 디렉터리에 둬. 그래야 이름 바꾸기 둘이 파일 시스템 하나 안에서 끝나. 재개는 단계 카운터 말고 디스크에 있는 걸 보고 정하게 해. 라이브 디렉터리 리비전이 뭔지, 준비 폴더가 아직 있는지. 그래야 어디서 죽었든 통해.
Progress
Progress is local-only — sign in to sync across devices.