정상 경로만 실행하면 성공 뒤의 기록 실패가 무엇을 남기는지 보기 어렵다. 원래 북극성의 저장·중지 시험에서 질문을 가져와 외부 전송 없는 작은 실험실을 만들었다. 어떤 단계에서 실패를 넣었고 실제로 그 지점까지 도달했는지, 합성 전달이 몇 번 있었는지를 함께 남긴다.

실패 위치를 고정된 목록에서 고른다

Lab은 준비 기록, 전달 기록, 네이티브 응답, 결과 확인, 마지막 기록의 실패를 고정된 이름으로 받는다. 중지는 준비 뒤·전달 직전·전달 뒤 중에서 고른다. 임의 콜백이나 실제 경로를 받아 동작하는 도구는 아니다. 하나의 run은 한 번만 시작할 수 있다.

이 제한은 실패를 고치는 자동 재시도기가 아니라 어떤 실패인지 관찰하는 실험이라는 목적을 드러낸다. 전달 전 실패여도 같은 객체의 run을 다시 호출하면 거절한다. 실제 운영에서 새 요청을 언제 허용할지는 현재 결과와 사람의 의도로 판단할 별도 문제다. 이 예제는 그 권한을 만들어 주지 않는다. 입력 이름을 바꾸면 어떤 효과가 달라지는지 작은 범위에서 비교하도록 했다.

기록의 이름과 실제 전달 횟수를 나눠 본다

메모리 checkpoint에 dispatched가 있어도 전달 직전 중지를 넣으면 send_count는0이다. 반대로 native_reply 실패는 합성 수신 효과가1번 생겼지만 결과를 읽지 못한 상태다. 원장의 단어 하나만 보고 실제 효과를 결정하면 서로 반대인 두 상황을 놓친다.

합성 수신기는 정해진 효과 개수만 늘리고 네트워크를 쓰지 않는다. 결과 관찰도 match·mismatch·unavailable이라는 선언된 조건으로 제한한다. expected_result_observed는 이 모델의 읽기 조건이 맞았다는 뜻이다. 실제 사이트 저장이나 결제를 증명하지 않는다. 예제의 효과와 검증 신호가 어디에서 나온 것인지 숨기지 않아야 실험 결과를 다른 제품에 잘못 빌려 쓰지 않는다.

마지막 기록 실패는 읽기 성공을 지우지 않는다

final_write 실패를 넣으면 결과를 읽어 맞다고 확인한 사실은 남지만 final_receipt_written은 False이고 상태는 needs_review다. 결과 확인에 성공했으니 기록도 저장됐을 것이라고 덮어쓰지 않는다. 반대로 마지막 기록이 없다는 이유로 합성 전달을 다시 하지도 않는다.

inspect는 복사한 현재 결과만 읽어 반환한다. 여러 번 읽어도 send_count가 늘지 않고 반환 객체를 바꿔도 내부 trace는 바뀌지 않는다. 코드의 memory_checkpoints는 실제 디스크 원장이 아니다. durable_receipt_verified는 정상 성공에서도 False다. 이 경계를 통해 읽기 확인, 기록 확인과 재전달 권한을 서로 다른 질문으로 남긴다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Deterministic, in-memory failure teaching lab. No I/O, timers or replay."""
from copy import deepcopy

FAULTS = frozenset({'none','prepare_write','dispatch_write','native_reply','verification','final_write'})
CANCELLATIONS = frozenset({'none','after_prepare','before_send','after_send'})
OBSERVATIONS = frozenset({'match','mismatch','unavailable'})

class Fault(Exception):
    pass

class Lab:
    def __init__(self, *, fault='none', cancel_at='none', observation='match'):
        for value, allowed in [(fault,FAULTS),(cancel_at,CANCELLATIONS),(observation,OBSERVATIONS)]:
            if type(value) is not str or value not in allowed:
                raise ValueError('INVALID_SCENARIO')
        self.fault, self.cancel_at, self.observation = fault, cancel_at, observation
        self._started = False
        self._state = dict(status='ready', stage='ready', trace=[], memory_checkpoints=[],
                           send_count=0, simulated_effect_count=0, read_count=0,
                           expected_result_observed=False, final_receipt_written=False,
                           cancellation_observed=False, fault_requested=fault,
                           fault_exercised=False, outcome='not_dispatched', error=None,
                           real_network_used=False, real_filesystem_used=False,
                           durable_receipt_verified=False, automatic_replay=False)

    def inspect(self):
        """Read only; even repeated inspection cannot send or certify persistence."""
        return deepcopy(self._state)

    def _point(self, stage):
        self._state['stage'] = stage
        self._state['trace'].append(stage)
        if stage == self.fault:
            self._state['fault_exercised'] = True
            raise Fault(stage)

    def _cancel(self, stage):
        if stage == self.cancel_at:
            self._state['cancellation_observed'] = True
            self._state['trace'].append('cancel:'+stage)
            raise Fault('cancelled')

    def run(self):
        if self._started:
            raise ValueError('RUN_ALREADY_STARTED')
        self._started = True
        s = self._state
        s['status'] = 'running'
        try:
            self._point('prepare_write')
            s['memory_checkpoints'].append('prepared')
            self._cancel('after_prepare')
            self._point('dispatch_write')
            s['memory_checkpoints'].append('dispatched')
            self._cancel('before_send')
            # This is a synthetic receiver, not an external operation.
            s['send_count'] += 1
            s['simulated_effect_count'] += 1
            s['outcome'] = 'unknown'
            s['trace'].append('synthetic_send')
            self._cancel('after_send')
            self._point('native_reply')
            s['read_count'] += 1
            self._point('verification')
            if self.observation == 'match':
                s['expected_result_observed'] = True
                s['outcome'] = 'observed_match'
            else:
                s['outcome'] = 'observed_mismatch' if self.observation == 'mismatch' else 'unknown'
                raise Fault('result_not_verified')
            self._point('final_write')
            s['memory_checkpoints'].append('succeeded')
            s['final_receipt_written'] = True
            s['status'] = 'succeeded'
            s['stage'] = 'finished'
        except Fault as e:
            s['error'] = str(e)
            s['status'] = 'needs_review' if s['send_count'] else ('cancelled' if s['cancellation_observed'] else 'failed')
        self.assert_invariants()
        return self.inspect()

    def assert_invariants(self):
        s = self._state
        assert s['send_count'] in {0,1}
        assert s['simulated_effect_count'] == s['send_count']
        assert s['read_count'] in {0,1}
        if s['status'] == 'succeeded':
            assert s['send_count']==1 and s['read_count']==1
            assert s['expected_result_observed'] and s['final_receipt_written']
            assert not s['cancellation_observed'] and s['error'] is None
        if not s['send_count']:
            assert s['outcome']=='not_dispatched' and not s['expected_result_observed']
        assert not s['automatic_replay'] and not s['durable_receipt_verified']

요청한 실패와 실제 넣은 실패를 따로 기록한다

마지막 기록 실패를 요청했어도 준비 직후 중지하면 그 실패 지점에는 도달하지 않는다. 이때 fault_exercised는 False다. 시험 이름에 실패를 적었다는 이유로 해당 실패를 검증했다고 보고하지 않는다. 이런 도달 여부가 빠지면 조기 종료한 시험이 오히려 모든 실패에 강한 코드처럼 보일 수 있다.

독립 시험28개에는 모든 선언 조합에서 전달 최대1회, 정상 완료의 읽기·기록 조건, 반환 복사와 반복 실행 거절을 포함했다. 조합 검사는 한 시험 안의 여러 경우이며 개별 시험 수에 덧붙이지 않는다. 다섯 기록은 실제 로컬 함수 실행에서 저장했고 아래 선택기는 그 결과를 읽는다. 원본 앱이나 외부 서비스 시험은 새로 실행하지 않았다.

운영에 옮길 때는 실제 저장과 복구를 더해야 한다

코드·시험·기록 생성기와 설명을 수정하고 재사용할 수 있다. 이 모델에는 비동기 스레드, 타이머, 파일 시스템, DB, 브라우저, 인증이나 강제 종료 복구가 없다. 순서를 명시해 비교하는 실험이며 실제 경쟁의 모든 타이밍을 시험한 것이 아니다.

실제 프로그램에는 영속 기록의 원자성, 서버에서 읽는 결과와 재시작 뒤의 상태 대조가 더 필요하다. 나는 예제가 작은 질문을 분명하게 답하는 데서 공유의 가치를 찾았다. 어떤 지점에서 실패했고 전달은 몇 번 있었으며 무엇을 아직 확인하지 못했는지를 먼저 남기면 정상 성공만 반복하는 시험에서 보이지 않던 설계 문제를 찾을 수 있다.

합성 로컬 기록의 읽기 뷰어
코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능