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

주인 파일은 닫힌 채로 실패해

~13 min · compatibility, fail-closed, receipts, rpc, migration

Level 0메모 쪽지
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

호출 전부를 옮기는 작은 파일

지난 레슨의 펜스는 저장소를 직접 여는 writer를 그 자리에서 세워. 그러면 곤란한 질문이 하나 생겨. 엔진이 생기기 전에도 Coop 장부엔 정직한 호출자가 잔뜩 있었거든. Pippa 세션 전부, 헬퍼 스크립트 전부, Almanac의 브리지까지. 다들 python -m tools.coop로 말하고 그 함수를 import해서 썼어. 전환하는 날 호출자를 하나하나 다시 쓰면 마이그레이션 위에 마이그레이션을 또 얹는 꼴이야. 그래서 전환은 데이터베이스 경로도 호출 방식도 그대로 뒀어. 바꾼 건 딱 하나. 저장소 옆에 놓인 작은 JSON 파일, <store>.owner.json.

이 파일엔 mode: service가 적혀 있어. 자기가 다스리는 데이터베이스, 주인의 URL, 비공개 자격 증명을 가리키고, 이관 시각이랑 이관 전 백업 위치도 남겨. 호환 클라이언트는 이 파일을 호출할 때마다 찾아. 호환 클라이언트란 cwkPippa가 tools.coop로 들고 다니는, Coop 도메인 코드의 고정 사본이야. 그래서 전환 때 돌고 있던 Pippa 턴을 죽일 필요가 없었어. 다음 호출이 그냥 파일을 발견했으니까.

라우팅, 그리고 코드가 로컬에서 도는 단 한 곳

도메인 연산은 전부(add_todo, take_todo, list_history 같은 것들) routed로 감싸여 있어. 주인 파일이 있으면 이 래퍼는 함수를 실행하지 않아. 메서드 이름, 인자, 호출자 메타데이터를 HTTP로 주인의 호환 엔드포인트에 보내. 같은 도메인 코드가 로컬에서 도는 곳은 딱 하나, 주인 자신의 요청 맥락 안이야. 엔진이 연산을 실행할 땐 맥락이 켜져 있으니까 래퍼가 비켜서고, 함수는 엔진이 빌려준 펜스 걸린 연결 위에서 돌아. 코드베이스는 하나, 역할은 둘, 정하는 건 호출마다.

제일 아래 문도 잠겨 있어. 저장소를 직접 열 때 쓰는 헬퍼 connect()가 주인부터 확인하고 바로 거절해. Direct SQL is disabled; use the Coop service API. 그러니 저장소가 있는 그 기계에서도 읽기마저 서비스를 거쳐. 주인 파일이 이상해도 대충 넘어가지 않아. 모드가 service가 아니거나 다른 데이터베이스를 가리키면 Invalid Coop owner configuration을 던져. JSON부터 깨졌거나, service라고 해놓고 데이터베이스 칸을 빠뜨린 파일은 이 문구까지 못 가. 파이썬 자체의 JSONDecodeError나 KeyError로 튀어나와. 문구는 달라도 결과는 같아. 예외가 owner_config()를 빠져나가는 건 호출을 보내거나 연결을 열기 전이라, 아무것도 진행 안 해.

영수증이 요청보다 먼저야

클라이언트는 한 바이트 보내기 전에 먼저 자격 증명 파일이 비공개인지 봐. 그룹이나 다른 사용자 비트가 하나라도 있으면 Coop credential must be private (0600)로 거절해. 그다음 새 요청 키를 만들고, 비공개 디렉터리에 상태 uncertain인 영수증을 써. 영수증엔 키, 메서드, 입력의 SHA-256만 들어가. 본문이나 자격 증명은 절대 안 들어가. 배타적으로 만들고, 0600으로 맞추고, fsync까지 해. HTTP 호출은 그다음이야.

왜 먼저냐고? 요청이 프로세스 경계를 넘는 순간, 답이 돌아오기 전까지 이쪽에선 그 운명을 알 수가 없어. 나중에 쓰는 영수증은 제일 필요할 때 사라져. 보내고 답 받기 사이에 크래시가 나거나 연결이 끊기면 말이야. 먼저 써두면 그 전부를 버텨. 그리고 쓰기라면, 영수증에 든 키가 바로 엔진이 그 명령을 적어두는 키야(엔진이 그걸로 뭘 하는지는 다음 레슨에서 봐).

닫힌 채로 실패하기, 정확한 문구로

뭐가 잘못됐나클라이언트가 하는 말영수증
주인한테 못 닿음 (네트워크나 소켓 오류)Coop owner unavailable; no local write attempted; reconcile request <key>uncertain 그대로
주인이 터짐, 도메인 판정 없음 (5xx)Coop owner failed (HTTP n); reconcile request <key>uncertain 그대로
실행 전에 거절 (4xx, 판정 없음)Coop refused the request (HTTP n): ...rejected
도메인이 안 된다고 함도메인 자기 오류를 그대로 다시 던짐rejected
성공결과committed

첫 줄은 천천히 읽어. 구절마다 약속이거든. No local write attempted: 클라이언트는 절대 직접 SQLite를 여는 쪽으로 물러서지 않아. 그렇게 물러선 writer가 바로 펜스가 막으려는 두 번째 writer니까. Reconcile request: 클라이언트는 다시 보내지도 않아. 코드 주석에 그대로 적혀 있어. do not retry a request with an uncertain HTTP outcome. 그 요청이 이미 커밋됐을 수도 있으니까. 대신 키를 건네줘. Coop 테스트는 세션 도중에 서비스를 죽이고, 다음 add_todo가 no local write가 든 메시지로 터지는지 확인해.

영수증 상태에도 버그가 있었어. 2512cf9(2026-09-29) 전까지는 서비스가 거절한 호출도 committed로 기록될 수 있었어. 그 보강 작업이 rejected를 따로 떼어냈고. 정리된 영수증은 하루가 지나면 지워져. 정리는 많아야 한 시간에 한 번이고(사흘 만에 디렉터리에 파일이 537개 쌓였었거든), uncertain인 건 누가 대조할 때까지 남겨둬.

대체 경로는 변장한 두 번째 writer야. 주인이 없을 때 정직한 답은 뭘 대조해야 하는지 이름을 대는 요란한 오류야. 조용한 로컬 쓰기는 가용성을 사는 대신 저장소가 가진 유일한 보장을 내다 팔아.

Code

Coop 호환 클라이언트: 주인 찾기, 라우팅, 직접 SQL 거절 (일부 발췌)·python
# Coop's compatibility client: owner discovery, routing, the direct-SQL refusal
# (trimmed excerpt)

def owner_config():
    path = Path(os.environ.get("PIPPA_COOP_OWNER_FILE", str(db_path()) + ".owner.json"))
    if not path.exists():
        return None
    config = json.loads(path.read_text())
    if config.get("mode") != "service" or Path(config["database"]).resolve() != db_path().resolve():
        raise OwnerUnavailable("Invalid Coop owner configuration")
    return config


def routed(fn):
    @wraps(fn)
    def call(*args, **kwargs):
        config = None if context.active() else owner_config()
        return remote(fn.__name__, args, kwargs, config) if config else fn(*args, **kwargs)
    return call


def connect() -> sqlite3.Connection:
    borrowed = context.connection()
    if borrowed is not None:
        return borrowed
    if owner_config() is not None:
        raise OwnerUnavailable("Direct SQL is disabled; use the Coop service API")
    ...
Coop 호환 클라이언트: 주인에게 보내는 호출 하나 (일부 발췌)·python
# Coop's compatibility client: one call to the owner (trimmed excerpt)

def remote(name, args, kwargs, config):
    token_path = Path(config["token_file"])
    if token_path.stat().st_mode & 0o077:
        raise OwnerUnavailable("Coop credential must be private (0600)")
    payload = {"method": name, "args": args, "kwargs": kwargs,
               "key": str(uuid.uuid4()),
               "metadata": {k: context.metadata(k) for k in ("id", "model", "reasoning", "session", "host")}}
    # Record the request ID before crossing the process boundary. Receipts contain
    # input hashes, not private bodies or credentials. Reconcile this ID on outage.
    receipt = {"key": payload["key"], "method": name, "state": "uncertain",
               "input_sha256": hashlib.sha256(json.dumps(payload, sort_keys=True, default=encode).encode()).hexdigest()}
    ...  # created exclusively, mode 0600, flushed and fsync'd before the request

    try:
        # Do not retry a request with an uncertain HTTP outcome.
        with urllib.request.urlopen(req, timeout=30) as response:
            result = json.loads(response.read(), object_hook=decode)
    except urllib.error.HTTPError as exc:
        ...  # no domain verdict: 4xx settles "rejected", 5xx stays uncertain
             # "Coop refused the request (HTTP {code}): ..."
             # "Coop owner failed (HTTP {code}); reconcile request <key>"
    except (OSError, ValueError) as exc:
        raise OwnerUnavailable("Coop owner unavailable; no local write attempted; reconcile request "
                               + payload["key"]) from exc
    ...
    settle("rejected" if "error" in result else "committed")

External links

Exercise

네 코드에서 서비스랑 말하는 클라이언트 중에, 서비스가 죽었을 때 쓰는 대체 경로가 있는 걸 하나 찾아봐. 써두는 로컬 캐시, 큐 파일, 데이터베이스 직접 연결 같은 거. 그 대체 경로에 대해 적어봐. 거기 쓴 내용을 나중에 서비스 상태랑 누가 맞추는지, 서비스가 원래 요청을 사실 받았으면 어떻게 되는지. 맞추는 사람이 없으면 닫힌 채로 실패하는 버전을 스케치해. 호출 전에 쓰는 영수증, 그 영수증 이름을 대는 요란한 오류, 로컬 쓰기 없음.
Hint
위험한 대체 경로는 쓰기가 성공한 것처럼 보이게 만드는 쪽이야. 장애 동안 읽기만 내주는 캐시는 무해해. 쓰기를 받아주는 캐시는 조용히 두 번째 writer가 된 거고, 안 써둔 대조 로직이 바로 버그야.

Progress

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

댓글 0

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

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