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

준비하고, 백업하고, 통째로 바꿔

~15 min · 파일 시스템, 원자성, 장애 복구, python

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

이겼을 뻔한 JPEG

피파는 프레임을 확장자 없이 이름으로 내줘. /avatars/{who}/{preset}/{emotion} 라우트는 정해진 목록 .jpeg, .jpg, .png, .webp를 차례로 훑고, 처음 있는 파일을 내줘. 소울 라우트도 소울 루트와 그 default 폴더에서 똑같이 해. 이제 제일 뻔한 발행 방법을 떠올려 봐. 새 default.png를 라이브 폴더에 복사하는 거. 폴더에 옛 default.jpg가 아직 있으면 피파는 계속 옛 얼굴을 내줘. 실패도 없고 로그도 없고, 발행은 적용된 것처럼 보여. 아키텍처는 이 함정을 한 줄로 짚어. 옛 JPEG가 새로 설치한 PNG를 가리면 안 된다고.

답은 라이브 폴더에 절대 섞어 넣지 않는 거야. 피파는 완성된 새 폴더를 다른 데서 만들고, 옛 폴더를 통째로 갈아 끼워. 리비전 함수도 서버랑 같은 편이야. frame_files는 확장자를 거꾸로 훑어. 그래서 두 파일이 같은 프레임 이름을 쓰면, 라우트가 내줄 그 파일이 해시에 들어가.

건드리기 전에 다 확인해

publish는 검사 순서를 이렇게 짜. 전부 통과하기 전엔 라이브 폴더에 손이 안 닿게.

  1. 요청 id 모양이 안전한지 봐. 아는 id에 지문까지 같으면 곧장 그 영수증으로 가.
  2. 대상을 레지스트리에서 풀어. 올바른 채널, 슬러그 이름, 아바타 루트가 있는 활성 주체여야 해.
  3. 라이브 리비전이 예상한 값과 같은지 봐.
  4. 세트가 갖출 건 갖췄는지 봐. 프레임이 하나 이상이고, 그중에 default가 있어야 해.
  5. 원본 경로가 전부 Avatars 원본 디렉터리 안으로 풀리는지, 이미지 파일인지, 적어 둔 SHA-256과 아직 같은지 봐.
  6. 주체가 전부 지금 레지스트리에서 활성인지 보고, 공개 소울이 보호 주체를 받지 못하게 해. 이 규칙은 트랙 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
def test_publication_preserves_backup_native_bytes_and_idempotency(authority):
    body, target, entries = authority
    receipt = studio.publish(body)
    assert (target / "default.png").read_bytes() == b"native candidate bytes"
    assert not (target / "default.jpg").exists()
    assert (Path(receipt["backup"]) / "previous/default.jpg").read_bytes() == b"original image bytes"
    assert studio.publish(body) == receipt
    manifest = json.loads((target / "avatar-lineage.json").read_text())
    assert manifest["owner"] == "cwkAvatars"
    assert manifest["role"] == "published-copy"
    assert manifest["collection_id"] == "collection"
    assert "path" not in manifest["frames"]["default"]
    with pytest.raises(ValueError):
        studio.publish({**body, "expected_revision": "different"})

External links

Exercise

네 앱이 내주는 파일 디렉터리를 설치하는 함수를 써 봐. 순서는 이래. 먼저 입력을 검증해. 라이브 디렉터리를 백업하고 백업 해시를 확인해. 대상 옆에 완성된 준비 폴더를 만들고, 경로를 전부 적은 영수증을 써. 마지막으로 이름 바꾸기 두 번으로 갈아 끼우고 검증해. 첫 이름 바꾸기 뒤에 예외를 던지는 테스트 훅을 넣어. 설치를 돌려서 죽게 하고, 같은 요청 id로 다시 돌린 다음, 라이브 디렉터리가 완성돼 있고 백업엔 옛 게 그대로 있는지 확인해.
Hint
준비 폴더는 대상의 부모 디렉터리에 둬. 그래야 이름 바꾸기 둘이 파일 시스템 하나 안에서 끝나. 재개는 단계 카운터 말고 디스크에 있는 걸 보고 정하게 해. 라이브 디렉터리 리비전이 뭔지, 준비 폴더가 아직 있는지. 그래야 어디서 죽었든 통해.

Progress

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

댓글 0

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

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