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

사본이 단단해진 뒤에만 놓아줘

~13 min · 내구성, 핸드셰이크, 보존 정책, 순서

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

보관소 둘, 그리고 넘겨주기

Avatars가 요청한 이미지는 전부 Ember에서 태어나. Ember가 원본과 Pro 기록 행을 먼저 저장해. 아키텍처 문서가 Ember를 생성물의 1차 저장소라고 부르는 이유야. Avatars는 그 뒤에 단단한 사본을 하나 더 들고 있어. Ember가 기록을 정리하더라도 아빠가 고른 아바타가 사라지는 일은 없게 하려는 거야. 같은 바이트를 보관소 둘이 들고 있으면 질문이 하나 생겨. 첫 번째 보관소는 언제 손을 놔도 될까?

Ember는 보호 표시로 답해. Avatars는 잡마다 purpose: "exploration"과 source_app: "cwkAvatars"를 달아. Ember 문서는 탐색 결과를 쓰는 쪽을 두고 짧게 설명해. 새 탐색 결과는 보호된 채로 시작해. 쓰는 쪽이 실제 바이트를 단단하게 복사하고, PATCH /api/v1/explorations/{id}/protection에 {"protected": false, "durable_copy": true}를 보내 확인해 줄 때까지 그 보호가 유지돼. 제출할 때 메타데이터로 복사하겠다고 약속해도 풀리는 건 없어. Ember의 앱별 만료는 누가 그 앱에 켜기 전까진 꺼져 있어. 보존 정리와 수동 정리도 이름을 댄 앱의 이미지 중에서 보호가 풀린 것만 골라.

그러니까 Avatars 쪽 규칙은 순서의 문제야. 호출 위의 주석이 그대로 말해. 원본 바이트와 메타데이터가 둘 다 Avatars에 단단히 자리 잡은 뒤에야 보호를 푼다고.

finish() 안의 순서

끝난 잡이 Avatars 자산이 되는 곳이 finish()야. 한 단계씩 보면 이래.

  1. 이 작업이 이미 만든 자산이 있는지 찾아. 있으면 바이트는 이미 안전하고, 기껏해야 Ember 기록 id만 채워 넣어. 두 번째 호출이 다시 받아오거나 다시 저장하는 일은 없어.
  2. 없으면 바이트를 받아와. 킷의 클라이언트로 Ember의 이미지 파일 경로에서 가져와.
  3. 원본을 저장해. save_asset은 바이트를 이미지로 열어서 진짜 이미지인지 확인하고, SHA-256으로 해시를 떠서 그 해시를 이름으로 써. 새로 만들 때만 열리는 임시 파일에 쓰고, 비우고, fsync하고, 원자적인 os.replace로 제자리에 옮겨. 같은 이름의 파일이 이미 있으면 해시가 여전히 맞아야 해. 이어서 피사체, 레퍼런스, 작업 id, 배치 id, 공급자, 모델, 프롬프트를 담은 자산 행이 SQLite에 들어가.
  4. 이제야 놓아줘. 킷의 release가 PATCH를 보내고, 200일 때만 True를 돌려줘.
  5. 결과를 적어. 작업은 done이 되고, Ember가 기록 id를 끝내 안 줬다면 history_missing이 돼. 놓아준 결과는 작업에 protection_released로 남아.

원본을 왜 해시 주소로 한 번만 쓰는지는 트랙 2에서 다뤄. 여기서 볼 건 4단계의 자리야.

확인 실패는 안전한 실패야

단계마다 죽으면 뭐가 남는지 봐. 3단계가 끝나기 전에 엔진이 죽으면, Ember엔 보호된 이미지가 있고 Avatars엔 아무것도 없어. 작업은 Ember의 답을 저장한 채 copying이니까, 다음 시작 때 아무것도 새로 만들지 않고 다시 복사해. 3과 4 사이에서 죽으면 양쪽 다 이미지를 들고 있고, Ember 쪽은 보호된 채로 있어. 작업이 여전히 copying이라 다음 시작 때 finish()가 다시 돌고, 저장된 자산을 찾은 다음 그때 놓아달라고 해. PATCH 자체가 실패하면 release가 전송 오류를 잡고 False를 돌려줘. 절대 예외를 던지지 않으니까, 네트워크가 흔들려도 저장된 이미지가 실패한 작업으로 바뀌진 않아. 근데 그 False는 확인을 못 받았다는 뜻이지, 아직 보호돼 있다는 뜻이 아니야. Ember가 PATCH를 이미 적용했는데 답이 오다가 연결이 끊기면, 보호는 벌써 풀렸는데도 False가 와. 킷의 독스트링도 호출 위 주석도 확인이 실패하면 보호된 채로 남는다고 적어 놨지만, 그건 흔한 경우일 뿐 보장은 아니야. 여기선 그래도 손해가 없어. 4단계에 오기 전에 Avatars가 이미 단단한 사본을 들고 있거든.

순서를 뒤집으면 같은 실패가 전혀 다르게 보여. 먼저 놓아주고 복사 전에 죽으면, 하나뿐인 원본이 Ember 정리 대상이 되는데 Avatars엔 아무것도 없어. 지금 순서라면 무슨 실패가 나든 원본은 Avatars에 있거나, Ember에서 아직 보호돼 있거나, 둘 다야. 치르는 값은 기껏해야 Ember에 남는 사본 하나야. 반대 순서의 최악은 누군가 돈을 내고 만든 이미지를 잃는 거고.

Avatars엔 실패한 놓아주기를 정해진 때에 다시 시도하는 장치가 없어. 표시는 작업에 남고, 이미지는 아마 Ember에서 보호된 채로 있을 거야. 그 작업의 finish()가 다음에 돌 때(기록 복구 같은 때) 다시 요청할 뿐이야.

history_missing: 바이트는 안전하고, 기록은 아니야

Ember는 이미지를 저장해 놓고도 Pro 기록 행 쓰기에 실패할 수 있어. 그럴 땐 잡을 실패시키는 대신 이미지 옆에 history_error를 붙여 알려줘. Avatars는 원본을 그대로 지키고, 작업을 history_missing으로 표시해. 단계 문구는 Original saved; history needs repair야. 원본은 저장됐고 기록은 고쳐야 한다는 뜻이야. 작업은 Ember의 오류 문장도 같이 들고 있어.

이 상태가 막는 건 딱 하나, 발행이야. 고른 이미지 중에 Ember 기록 id가 없는 생성 이미지가 있으면 세트 발행이 거절돼. 생성 이미지를 발행하려면 Ember 기록부터 복구하라는 거야. 복구는 대조를 거쳐(앞 레슨). 이 작업 id를 단 기록 행이 Ember에 생기면 검색이 그걸 찾고, finish()가 다시 돌아. 이번엔 자산이 이미 있으니 기록 id만 적어. 테스트는 finish()를 두 번 불러. 처음엔 기록 행 없이, 두 번째엔 기록 행과 함께. 그리고 Ember 이미지 파일을 딱 한 번만 받아왔는지 확인해. 아키텍처는 이 경우를 이렇게 정리해. 기록 삽입에 실패한 보관 이미지는 고칠 수 있는 메타데이터 작업일 뿐, 생성을 한 번 더 청구해도 된다는 허가가 아니라고.

Code

cwkAvatars, backend/worker.py: 먼저 복사하고 기록하고, 놓아주기는 맨 마지막 (원문 그대로, 222-241행)·python
    async def finish(self, id, record):
        io, upstream = self.studio.io, self.studio.upstream
        task = await io("get", id, "task")
        existing = next((a for a in await io("list", "asset") if a.get("task_id") == id), None)
        result = record.get("result") or {}
        if existing:
            asset = existing
            history_id = (result.get("history") or {}).get("id")
            if history_id and not asset.get("ember_history_id"):
                asset = await io("update", asset["id"], ember_history_id=history_id, upstream=result)
        else:
            batch = await io("get", task["parent"], "batch")
            image = await upstream.jobs.fetch(record)
            metadata = (result.get("image") or {}).get("metadata") or {}
            label = f"{batch['title']} · {task['provider_label']} {task['ordinal']:02d}" + (f" · {task['frame']}" if task["frame"] != "image" else "")
            asset = await io("save_asset", image.data, label=label, subjects=task["subjects"], origin="generated", references=[r["id"] for r in task["references"]], task_id=id, batch_id=batch["id"], workflow=batch["request"].get("workflow", "studio"), ember_image_id=image.image_id, ember_history_id=(result.get("history") or {}).get("id"), provider=metadata.get("provider") or task["provider"], model=metadata.get("model"), prompt=task["prompt"], upstream=result)
        # Protection is released only after both native bytes and metadata
        # are durable in Avatars. A failed ack safely leaves it protected.
        released = await upstream.jobs.release(asset["ember_image_id"])
        await io("update", id, state="done" if asset.get("ember_history_id") else "history_missing", phase="Complete" if asset.get("ember_history_id") else "Original saved; history needs repair", asset_id=asset["id"], protection_released=released, error=result.get("history_error"))
cwkAvatars, backend/kit_ember_jobs.py (Sibling Kit에서 가져온 사본): 절대 예외를 던지지 않는 확인 (원문 그대로, 174-186행)·python
    async def release(self, image_id: str) -> bool:
        """Acknowledge a durable copy: Ember may now clean the exploration up.
        False (never raised) when Ember did not take it; the image then stays
        protected, the safe side."""
        try:
            async with self._client() as client:
                response = await client.patch(
                    f"/api/v1/explorations/{image_id}/protection",
                    json={"protected": False, "durable_copy": True},
                )
        except httpx.HTTPError:
            return False
        return response.status_code == 200
cwkAvatars, backend/store.py: 해시 이름으로 한 번만, fsync와 원자적 이름 바꾸기를 거쳐 쓰기 (원문 그대로, 174-187행)·python
    def write_original(self, filename: str, data: bytes, digest: str) -> Path:
        """Content-addressed and written once; an existing file must still match."""
        path = self.root / "originals" / filename
        if path.exists():
            if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
                raise StudioError("Stored original failed its checksum.", 503)
        else:
            temporary = path.with_name(f".{identity()}.tmp")
            with temporary.open("xb") as handle:
                handle.write(data)
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(temporary, path)
        return path

External links

Exercise

지금 쓰는 스택 안에서 저장소 둘 사이의 넘겨주기를 만들어봐. 한쪽은 파일을 만들고 정리 대상에서 보호해 둬. 다른 쪽은 그걸 받아와서 임시 파일에 쓰고, 비우고, fsync하고, 제자리로 이름을 바꾸고, 자기 데이터베이스에 기록해. 첫 번째 쪽의 보호를 푸는 확인은 그 뒤에야 불러. 확인은 참거짓을 돌려주고 절대 예외를 던지지 않게 해. 준비가 되면 받는 쪽을 단계마다 죽이고, 그때마다 만드는 쪽의 정리를 돌려봐. 어떤 단계에서도 파일이 양쪽 모두에서 사라질 수 없는지 확인해.
Hint
죽이는 지점마다 어느 쪽이 파일을 들고 있고 보호돼 있는지 표로 적어. 받는 쪽엔 없고 만드는 쪽은 보호가 풀린 줄이 하나라도 있으면 순서가 틀린 거야. 받는 쪽을 두 번째로 돌리면 자기가 전에 만든 사본을 찾아서 받아오기를 건너뛰어야 해.

Progress

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

댓글 0

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

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