~12 min · batch, caching, idempotence, cli, resumability
Level 0불 꺼진 작업장
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
작업장엔 다른 종류의 복원력이 필요해
상시 서비스는 감시에서 복원력을 얻어. 누가 지켜보다가 죽으면 알아채고 다시 띄워주고, 필요한 상태는 크래시에서 살아남은 데이터베이스에 있어. 작업장엔 그런 게 하나도 없어. 그래서 답이 있어야 해. 영상 렌더는 비싼 단계로 이뤄진 긴 작업이거든. 글자 수만큼 돈 나가는 음성 합성, 장당 돈 나가는 이미지 생성, 분 단위로 시간 먹는 인코딩.
답은 모든 단계를 캐시하고 따로 다시 돌릴 수 있게 만드는 거야. 인코딩에서 죽은 실행은 합성을 다시 안 해. 다시 부르면 끝난 단계들이 자기 결과물이 이미 있다는 걸 알아채고, 멈춘 데서 이어져. 그래도 다시 만들고 싶으면 그건 의도적인 행동이야. force 플래그. 명령을 두 번 쳤다는 이유로 벌어지는 일이 아니라.
신선도는 가정이 아니라 질문이야
설계 전체가 작은 함수 하나에 들어 있어. 출력 경로랑 force 플래그가 주어졌을 때, 디스크에 있는 게 아직 쓸 만한가? 순진한 답 두 개가 실제로 벌어지는 실패 두 개를 만들어. "파일이 있으면 그렇다"고 답하면 네가 고치기 전에 만든 낡은 산출물을 아주 기쁘게 내줘. "아니, 매번 다시 만들어"라고 답하면 사려던 성질을 통째로 버린 거고.
쓸 만한 버전은 캐시된 산출물을 그걸 만들어낸 내용에 묶어. 장면 정의가 바뀌면 경로가 바뀌고, 옛날 픽셀은 아무도 안 보는 자리에 남아. 캐시가 낡은 답을 못 내줘. 그 주소를 이제 안 갖고 있으니까. 이거랑 정확히 같은 실패를 이 퀘스트 뒤쪽에서 만나게 될 거야. 구멍 난 캐시가 세 시간 묵은 이미지를 완성된 렌더에 밀어 넣은 사건.
단계를 어디서 자르냐가 설계야
단계 목록을 구현 세부사항으로 취급하기 쉬워. 긴 스크립트를 대충 토막 낸 거라고. 아냐. 모든 단계 경계는 일을 두 개씩 하고 있고, 둘 다 기계가 아니라 사람에 관한 거야.
경계는 일이 이어질 수 있는 지점이야. 이건 기계적인 쪽. 동시에 사람이 들여다볼 수 있는 지점이고, 이쪽이 중요해. 음성 합성이 독립 단계인 건 그 위에 뭘 쌓기 전에 사람이 테이크를 들어봐야 하기 때문이야. 판 만들기가 독립 단계인 것도 누가 그림을 봐야 하니까. 합성이랑 조립이 한 단계였으면 망가진 테이크를 처음 들을 기회가 인코딩 다음이었을 거야. 그리고 그걸 잡아내는 검토는 reroll 이 아니라 전체 재빌드 값을 물었겠지.
그러니까 자르는 자리를 정하는 기준은 "코드가 길어지는 데"가 아냐. 사람이 멈춰서 판정하고 싶어 할 자리에서 자르고, 그 판정에 기대는 비싼 일 앞에서 잘라라. 이 순서를 맞추면 검토가 실제로 벌어질 만큼 싸져. 틀리면 형식적으론 이어 돌릴 수 있는데 실질적으론 검토가 불가능한 파이프라인이 나와. 점검 지점이 전부, 그게 막았어야 할 지출의 하류에 앉아 있거든.
이름으로 캐시를 무효화하기. 경로 하나 저장해두고 신선한지 판정하지 말고, 입력의 다이제스트에서 경로를 뽑아내. 그럼 "이거 낡았나?"가 판단의 영역에서 빠져나와. 낡은 산출물은 아무도 안 묻는 주소에 살고, 맞는 건 있거나 없거나 둘 중 하나야.
Code
신선도 질문, 그리고 그 옆의 더 나은 답·python
def fresh(path: Path, force: bool) -> bool:
"""Naive: is there something already at this path?"""
return path.exists() and not force
# The problem: edit the scene, and this still says True.
# The fix is not a smarter check - it is a different address.
def scene_digest(sc: dict) -> str:
"""Everything that can change the pixels goes into the key."""
payload = json.dumps(sc, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(payload.encode()).hexdigest()[:12]
def scene_plate_path(ep: Path, sc: dict) -> Path:
# content-keyed: editing a scene cannot serve last run's pixels,
# because the edited scene simply has a different filename.
return ep / "plates" / f"{sc['key']}-{scene_digest(sc)}.png"
def scene_clip_path(ep: Path, sc: dict, dur: float) -> Path:
# keyed by the scene AND its length on the timeline - the same
# picture held for 4s and for 9s are two different artifacts.
return ep / "clips" / f"{sc['key']}-{scene_digest(sc)}-{dur:.2f}.mp4"
밖에서 보면 '끊겨도 되는' 게 이렇게 생겼어·bash
# a full run: each stage caches its outputs
pipeline.py all --episode my-episode
# ...dies during encode. re-invoke; synthesis and plates are kept.
pipeline.py all --episode my-episode
# rebuild one stage on purpose
pipeline.py plates --episode my-episode --force
# one bad speech take: purge exactly that segment, keep the rest.
# without this, re-synthesizing identical text is a cache hit
# that cheerfully returns the same glitched audio forever.
pipeline.py reroll seg-014 --episode my-episode
뒤쪽 단계가 비싼 다단계 스크립트를 하나 골라봐. 다운로드든 모형 호출이든 빌드든. 각 단계의 출력 경로를 입력 다이제스트에서 뽑아내게 바꾸고, 그 경로가 이미 있으면 단계를 건너뛰게 해. 그다음 진짜 중요한 경우를 시험해봐. 중간 입력을 하나 바꾸고, 영향받은 단계만 정확히 다시 만들어지는지 확인해. 전부도 아니고 아무것도 아니고.
Hint
노려야 할 버그는 결과에 영향을 주는데 다이제스트엔 안 들어간 입력이야. 플래그, 환경 변수, 디스크에서 읽어오는 템플릿 파일 같은 거. 결과를 바꿀 수 있는데 키에 안 들어간 건 전부, 언젠가 낡은 채로 튀어나올 산출물이야.
Progress
Progress is local-only — sign in to sync across devices.