data-scale · Stage 3

重複・順序・分断を再現し復旧境界を設計する

決定的event simulatorでat-least-once配信、順序入替、network分断、回復を再現し、永続dedupeと冪等な状態遷移を検証する。

学習時間
320分
難易度
advanced
更新日
2026-07-30
到達証拠
成果物・説明・判断根拠・転用

到達目標

  1. delay、duplicate、reorder、network partition、recoveryを、model assumptionと観測可能なevent traceへ分解できる

    • 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace
    • FLPの非同期consensus範囲、実用protocolの仮定、retryとexactly-onceの違いを説明する7分間の解説
  2. stableなdedupe key、永続結果、許可された状態遷移を結び、at-least-once retryで副作用を一度だけ適用して結果を再利用できる

    • 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace
    • message deliveryと状態遷移を分け、duplicate、reorder、長期分断で不変条件と復旧結果を反証する判断記録
  3. 分断期間を変更してreplayし、回復deadline、残留message、dedupe保持、利用者へ返す結果を再評価できる

    • message deliveryと状態遷移を分け、duplicate、reorder、長期分断で不変条件と復旧結果を反証する判断記録
    • 拠点間network分断を長期化し、dedupe retention、replay、reconciliation、利用者結果を更新した障害分析

能力の進行

  1. recognize

    message loss、delay、duplicate、reorder、partition、process recoveryを異なるfailure modeとして識別できる

    証拠: 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace

  2. explain

    FLPが扱う非同期consensusのscopeと、timeout・leader・quorumを使う実用protocolの仮定を区別して説明できる

    証拠: FLPの非同期consensus範囲、実用protocolの仮定、retryとexactly-onceの違いを説明する7分間の解説

  3. apply

    stable dedupe key、永続結果、冪等な状態遷移によりat-least-once retryを安全に処理できる

    証拠: 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace

  4. diagnose

    同じ固定seedでevent traceをreplayし、duplicate effect、順序依存、分断中の不確実な結果を切り分けられる

    証拠: message deliveryと状態遷移を分け、duplicate、reorder、長期分断で不変条件と復旧結果を反証する判断記録

  5. lead

    failure model、復旧deadline、dedupe retention、reconciliation、利用者契約を合意するreviewを主導できる

    証拠: 拠点間network分断を長期化し、dedupe retention、replay、reconciliation、利用者結果を更新した障害分析

なぜ重要か

distributed systemでは、timeoutは処理が失敗した証拠ではない。requestが届かなかった、処理後のresponseだけ失われた、遅れて到着した、別経路で順序が入れ替わった、networkが分断された、という複数の状態を同じ観測からは区別できない。client retryだけを加えると、元の処理が成功していた場合に副作用を重複させる。

安全な設計は、message deliveryとbusiness state transitionを分ける。at-least-once配信を前提にstableなdedupe key、入力fingerprint、最初のresult、状態遷移をatomicに永続化し、duplicateには保存済みresultを返す。さらに順序入替、分断期間、dedupe retention、recovery deadlineを変更して保証範囲を再評価する。

メンタルモデル

safetyは悪い状態へ到達しない性質、livenessは望む処理が最終的に進む性質である。分断中に安全を守るため処理を保留すると可用性やlatencyへ影響し、応答を優先すると後でconflict resolutionが必要になる。保証はnetwork model、fault model、clock、storage durability、quorum assumptionと一緒に読む。

部分障害時のcommand処理を選ぶdecision table
方針safetyliveness・latency必要な証拠主な残留risk
無条件retryduplicate effectを防げない短い障害では進みやすい副作用回数と重複率二重課金・二重確定
永続dedupe・result再利用同じkeyの状態遷移を一度に制限store lookupとretentionが必要atomic commit、key scope、duplicate replaykey衝突・期限切れ後の再送
分断中保留・回復後replay矛盾した更新を抑制分断期間だけ結果が遅れるqueue上限、順序、不変条件、recovery deadline長期分断・stale command
両側受付・後でreconcileconflict rule次第受付可用性を保ちやすいmerge rule、利用者意味、compensation不可逆なbusiness conflict

model scope: 教材は一つの注文state machine、永続dedupe store、delay・duplicate・reorder・partitionを起こすasynchronous networkを模した決定的scenarioである。Byzantine fault、real clock、disk故障、multi-leader consensus、実networkの確率分布は扱わない。

fixtureの由来: event、tick、recovery deadlineはlesson-authored simulationであり、production incidentやprotocol証明ではない。実systemでは匿名化trace、brokerのdelivery契約、storage durability、retention、chaos rehearsalで仮定を置き換える。

at-least-once commandを永続dedupeと回復へ接続するmechanism

response lossと再配送があっても副作用を一度だけ進めるdedupeの耐久境界はどこか。

  1. 重複排除

    stable keyと入力fingerprintで再配送を識別する。

    1. receive

      tenant、resource、operationを含むstable keyと入力fingerprintを受け取る。

      順序: 0

    2. lookup

      永続dedupe storeにkeyがあれば入力fingerprintを照合し、一致時だけ状態遷移を再実行せず最初のresultを再利用する。不一致はkey衝突として拒否する。

      順序: 1

  2. 耐久化

    状態遷移と再利用resultを同じ境界で保存する。

    1. apply

      keyがなければ現在stateから許可された次stateへ一度だけ遷移する。

      順序: 2

    2. commit

      state、fingerprint、resultを同じdurability境界で保存する。response lossはcommitを取り消さない。

      順序: 3

  3. 回復

    partition後の順序差と期限超過を再評価する。

    1. recover

      partition中のmessageを再開後に処理し、logical sequenceとdelivery orderの差を検査する。

      順序: 4

    2. re-evaluate

      partitionがdeadlineを越えたら、queue、retention、stale command、reconciliation、利用者結果を更新する。

      順序: 5

  4. 固定6 event log

    seed 20260731の同じ完全logをduplicate、reorder、partition、recoveryの各観点で読む。

    1. e1 confirm

      tick=1、kind=deliver、logical_sequence=2、delivery_priority=0。immediateにpending→confirmedを1回applyし、state・fingerprint・result=confirmed-onceをatomic commitする。

      順序: 6

    2. e2 partition start

      tick=2、kind=partition_start。partitionをactiveにし、e1の永続commitを保持したまま以後のmessageをbufferする。

      順序: 7

    3. e3 status read

      tick=3、kind=deliver、logical_sequence=4、delivery_priority=1。partition中なのでstatus readはapplyせずbufferし、利用者結果を未確定のまま保つ。

      順序: 8

    4. e4 confirm retry

      tick=4、kind=duplicate、logical_sequence=2、delivery_priority=2。同じkeyとfingerprintのretryをbufferし、回復時もeffectを再applyせず保存済みresultを再利用する。

      順序: 9

    5. e5 reconcile read

      tick=5、kind=deliver、logical_sequence=3、delivery_priority=0。partition中にbufferし、回復時はe3のsequence=4より先にgapを埋めるread-only resultを得る。

      順序: 10

    6. e6 partition end

      tick=6、kind=partition_end。deadline=8以内にrecoveryし、priority順e5→e3→e4で解放する。最終state=confirmed、apply=1、result reuse=1として順序差と残留messageを再評価する。

      順序: 11

stable keyとfingerprintの照合、stateとresultのatomic commit、partition回復後の再評価を説明できる。

パラメータと選択肢
パラメータ選択肢既定値
event traceの観測点duplicate delivery、reordered delivery、partition中のbuffer、tick 6で回復duplicate
  1. e1 deliver・apply・commit: e1-confirmをimmediate処理し、pending→confirmedを1回applyしてresult=confirmed-onceをatomic commitする。; 条件 常時; node e1-confirm; edge なし
  2. partition lens: e2〜e5: partition lens: e2〜e5でpartition active。e3・e4・e5の3 messageをbufferしbuffer=3、e1 commitを保持する。; 条件 常時; node e2-partition-starte3-status-reade4-confirm-retrye5-reconcile-read; edge なし
  3. e3 status readをbuffer: e3はsequence=4・priority=1。partition中なのでapplyせず、利用者結果を未確定のままbufferする。; 条件 常時; node e3-status-read; edge なし
  4. duplicate lens: e4: duplicate lens: e4 retryは保存済みresultを再利用してresult reuse=1、effect=1のまま再applyしない。; 条件 常時; node e4-confirm-retry; edge なし
  5. reorder lens: e5: reorder lens: e5がsequence gapを埋め、recovery release=e5→e3→e4としてdelivery順との差を示す。; 条件 常時; node e5-reconcile-read; edge なし
  6. recovery lens: e6: recovery lens: e6はtick=6、deadline=8以内にe5→e3→e4を解放し、final=confirmed、apply=1へ収束する。; 条件 常時; node e6-partition-end; edge なし
完全な遷移
イベント開始終了条件
parameter-changeevent-log-startduplicate-receivedevent-case=duplicate
parameter-changeevent-log-startreorder-gap-filledevent-case=reorder
parameter-changeevent-log-startpartition-detectedevent-case=partition
parameter-changeevent-log-startrecovery-convergedevent-case=recovery
nextevent-log-startpartition-detected常時
timerevent-log-startpartition-detected常時
previouspartition-detectedevent-log-start常時
resetpartition-detectedevent-log-start常時
nextpartition-detectedpartition-buffered常時
timerpartition-detectedpartition-buffered常時
previouspartition-bufferedpartition-detected常時
resetpartition-bufferedevent-log-start常時
nextpartition-bufferedduplicate-received常時
timerpartition-bufferedduplicate-received常時
previousduplicate-receivedpartition-buffered常時
resetduplicate-receivedevent-log-start常時
nextduplicate-receivedreorder-gap-filled常時
timerduplicate-receivedreorder-gap-filled常時
previousreorder-gap-filledduplicate-received常時
resetreorder-gap-filledevent-log-start常時
nextreorder-gap-filledrecovery-converged常時
timerreorder-gap-filledrecovery-converged常時
previousrecovery-convergedreorder-gap-filled常時
resetrecovery-convergedevent-log-start常時
観測結果
結果状態
duplicateは届くがstate transitionは1回、保存済みresultを1回再利用する。duplicate-received
logical sequenceとdelivery orderの差を検出し、gapを埋めてから再評価する。reorder-gap-filled
partition中の3 messageをbufferし、未確定結果を成功へ広げない。partition-detected
tick 6はdeadline 8以内。confirmedへ収束し、tick 11ならdeadline超過へ分岐する。recovery-converged

現在の状態: e1 deliver・apply・commit — e1-confirmをimmediate処理し、pending→confirmedを1回applyしてresult=confirmed-onceをatomic commitする。

このモデルは例示的かつ決定的であり、実システムの完全な再現ではありません。

動く例で考える

重複commandを一度だけ適用し、長期分断を再評価する

前提
固定seed 20260731、注文 order-42pending→confirmed 遷移、recovery deadline 8 tickを使う。配信はat-least-onceで、処理後responseが失われる可能性がある。
入力
最初のdeliver、partition start、分断中のread、同じdedupe keyのduplicate、reconcile read、partition endからなる6 eventを入力する。logical sequenceとdelivery priorityは独立している。
操作
partition中の3 messageをbufferし、回復後に固定priorityとseedで解放する。duplicateはcanonical input fingerprintを永続storeと照合し、一致時は状態遷移を再適用せず保存済みresultを返す。不一致時はtyped conflictとして拒否する。同じfixtureを二回replayする。
観測
二つのtraceは一致し、delivery orderはlogical sequenceと異なる。注文確定の状態遷移は1回、同一payloadのduplicateはresultを1回再利用する。同じkeyで異なる遷移を送るmutationはfingerprint不一致として拒否され、storeとstateは変わらない。分断終了を11 tickへ延ばすとdeadline 8を越え、回復outcomeが変わる。
結論
これはexactly-once deliveryではない。duplicateは実際に届くが、business effectをstable key、永続result、冪等state transitionで制御する。長期分断では成功扱いを広げず、不確実な結果とreconciliation責任を利用者へ示す。

次のPython 3.13 harnessは標準libraryと固定fixtureだけを使い、network、secret、shell、filesystemへアクセスしない。固定seedの二回replayとpartition duration mutationを内部assertで検証する。

python3.13 - <<'PY'
import hashlib
import json
import random

HARNESS = "coordination_simulator_lab_v1"
SEED = 20260731
RECOVERY_DEADLINE_TICK = 8
DEDUPE_KEY = "tenant-7:order-42:confirm:v1"

EVENTS = [
    {
        "id": "e1-confirm",
        "tick": 1,
        "kind": "deliver",
        "logical_sequence": 2,
        "delivery_priority": 0,
        "dedupe_key": DEDUPE_KEY,
        "transition": ["pending", "confirmed"],
    },
    {
        "id": "e2-partition-start",
        "tick": 2,
        "kind": "partition_start",
    },
    {
        "id": "e3-status-read",
        "tick": 3,
        "kind": "deliver",
        "logical_sequence": 4,
        "delivery_priority": 1,
        "dedupe_key": "read:order-42:status:1",
        "transition": None,
    },
    {
        "id": "e4-confirm-retry",
        "tick": 4,
        "kind": "duplicate",
        "logical_sequence": 2,
        "delivery_priority": 2,
        "dedupe_key": DEDUPE_KEY,
        "transition": ["pending", "confirmed"],
    },
    {
        "id": "e5-reconcile-read",
        "tick": 5,
        "kind": "deliver",
        "logical_sequence": 3,
        "delivery_priority": 0,
        "dedupe_key": "read:order-42:reconcile:1",
        "transition": None,
    },
    {
        "id": "e6-partition-end",
        "tick": 6,
        "kind": "partition_end",
    },
]

class IdempotencyConflict(Exception):
    code = "same-key-different-input"

    def __init__(self, key, stored_fingerprint, attempted_fingerprint):
        super().__init__(self.code)
        self.key = key
        self.stored_fingerprint = stored_fingerprint
        self.attempted_fingerprint = attempted_fingerprint

def canonical_input_fingerprint(event):
    # Delivery metadata changes across retries, so only the business input is
    # canonicalized. This makes semantically identical retries byte-stable.
    business_input = {"transition": event["transition"]}
    canonical_input = json.dumps(
        business_input,
        ensure_ascii=False,
        separators=(",", ":"),
        sort_keys=True,
    ).encode("utf-8")
    return hashlib.sha256(canonical_input).hexdigest()

def simulate(seed, partition_end_delay=0, conflicting_retry=False):
    rng = random.Random(seed)
    events = [
        {
            **event,
            "transition": ["confirmed", "cancelled"],
        }
        if conflicting_retry and event["id"] == "e4-confirm-retry"
        else event
        for event in EVENTS
    ]
    trace = []
    buffered = []
    delivery_sequences = []
    persistent_results = {}
    state = {"order-42": "pending"}
    applied_by_key = {}
    result_reuse_by_key = {}
    reused_result_by_key = {}
    commit_records = []
    conflicts = []
    partition_active = False
    partition_started = False
    recovery_tick = None
    released_message_count = 0
    volatile_generation = 1

    def process_message(event, delivery):
        nonlocal persistent_results, state
        key = event["dedupe_key"]
        input_fingerprint = canonical_input_fingerprint(event)
        delivery_sequences.append(event["logical_sequence"])
        if key in persistent_results:
            stored_entry = persistent_results[key]
            if stored_entry["input_fingerprint"] != input_fingerprint:
                raise IdempotencyConflict(
                    key,
                    stored_entry["input_fingerprint"],
                    input_fingerprint,
                )
            result_reuse_by_key[key] = result_reuse_by_key.get(key, 0) + 1
            reused_result_by_key[key] = stored_entry["result"]
            trace.append(
                {
                    "tick": delivery["tick"],
                    "kind": "deliver",
                    "event_id": event["id"],
                    "delivery": delivery["mode"],
                    "logical_sequence": event["logical_sequence"],
                    "effect": "reused-persistent-result",
                    "input_fingerprint": input_fingerprint,
                    "result": stored_entry["result"],
                }
            )
            return

        transition = event["transition"]
        next_state = dict(state)
        if transition is None:
            result = {
                "order_id": "order-42",
                "state": state["order-42"],
                "observation": event["id"],
            }
            effect = "read-only"
        else:
            before, after = transition
            assert state["order-42"] == before
            next_state["order-42"] = after
            result = {
                "order_id": "order-42",
                "state": after,
                "confirmation": "confirmed-once",
            }
            effect = "state-transition-applied"

        stored_entry = {
            "input_fingerprint": input_fingerprint,
            "result": result,
        }
        # The fixture publishes state and dedupe data from one modeled commit
        # boundary so no retry can observe only half of the business effect.
        next_persistent_results = {
            **persistent_results,
            key: stored_entry,
        }
        state, persistent_results = next_state, next_persistent_results
        if transition is not None:
            applied_by_key[key] = applied_by_key.get(key, 0) + 1
        commit_records.append(
            {
                "key": key,
                "state_after": dict(state),
                "stored_entry": stored_entry,
            }
        )
        trace.append(
            {
                "tick": delivery["tick"],
                "kind": "deliver",
                "event_id": event["id"],
                "delivery": delivery["mode"],
                "logical_sequence": event["logical_sequence"],
                "effect": effect,
                "input_fingerprint": input_fingerprint,
                "result": result,
            }
        )

    def deliver_message(event, delivery):
        state_before = dict(state)
        stored_entry_before = persistent_results.get(event["dedupe_key"])
        try:
            process_message(event, delivery)
        except IdempotencyConflict as error:
            stored_entry_unchanged = (
                state == state_before
                and persistent_results.get(error.key) == stored_entry_before
            )
            conflict = {
                "error_type": type(error).__name__,
                "code": error.code,
                "rejected": True,
                "key": error.key,
                "attempted_input_fingerprint": error.attempted_fingerprint,
                "stored_input_fingerprint": error.stored_fingerprint,
                "applied_state_transitions": applied_by_key.get(error.key, 0),
                "stored_entry_unchanged": stored_entry_unchanged,
            }
            conflicts.append(conflict)
            trace.append(
                {
                    "tick": delivery["tick"],
                    "kind": "deliver",
                    "event_id": event["id"],
                    "delivery": delivery["mode"],
                    "logical_sequence": event["logical_sequence"],
                    "effect": "idempotency-conflict-rejected",
                    "error": conflict,
                }
            )

    for event in events:
        if event["kind"] == "partition_start":
            partition_active = True
            partition_started = True
            trace.append(
                {
                    "tick": event["tick"],
                    "kind": "partition_start",
                    "event_id": event["id"],
                }
            )
        elif event["kind"] == "partition_end":
            recovery_tick = event["tick"] + partition_end_delay
            partition_active = False
            volatile_generation += 1
            trace.append(
                {
                    "tick": recovery_tick,
                    "kind": "partition_end",
                    "event_id": event["id"],
                }
            )
            trace.append(
                {
                    "tick": recovery_tick,
                    "kind": "recovery",
                    "event_id": "node-recovered",
                    "volatile_generation": volatile_generation,
                    "persistent_dedupe_entries": len(persistent_results),
                }
            )
            # Priority models an explicit recovery policy; seeded jitter only
            # resolves ties so replay remains deterministic.
            release_order = sorted(
                buffered,
                key=lambda item: (
                    item["delivery_priority"],
                    rng.random(),
                ),
            )
            for buffered_event in release_order:
                deliver_message(
                    buffered_event,
                    {
                        "tick": recovery_tick,
                        "mode": "released-after-partition",
                    },
                )
                released_message_count += 1
        elif partition_active:
            buffered.append(event)
            trace.append(
                {
                    "tick": event["tick"],
                    "kind": event["kind"],
                    "event_id": event["id"],
                    "delivery": "buffered-by-partition",
                    "logical_sequence": event["logical_sequence"],
                }
            )
        elif event["kind"] in {"deliver", "duplicate"}:
            trace.append(
                {
                    "tick": event["tick"],
                    "kind": event["kind"],
                    "event_id": event["id"],
                    "delivery": "received",
                    "logical_sequence": event["logical_sequence"],
                }
            )
            deliver_message(
                event,
                {"tick": event["tick"], "mode": "immediate"},
            )
        else:
            # A control event reaching the message path means partition
            # handling was disabled. Diagnose that causal contract explicitly
            # instead of failing later on message-only fields by accident.
            raise AssertionError(
                "partition-causal-invariant: "
                f"unhandled control event {event['kind']}"
            )

    reorder_observed = any(
        current > following
        for current, following in zip(
            delivery_sequences,
            delivery_sequences[1:],
        )
    )
    recovery_outcome = (
        "recovered-before-deadline"
        if recovery_tick is not None and recovery_tick <= RECOVERY_DEADLINE_TICK
        else "recovery-deadline-exceeded"
    )
    stored_entry = persistent_results[DEDUPE_KEY]
    matching_commits = [
        commit
        for commit in commit_records
        if commit["key"] == DEDUPE_KEY
    ]
    state_fingerprint_result_atomic = (
        len(matching_commits) == 1
        and matching_commits[0]["stored_entry"] == stored_entry
        and matching_commits[0]["state_after"] == state
    )
    return {
        "trace": trace,
        "partition_started": partition_started,
        "buffered_message_count": len(buffered),
        "delivery_sequences": delivery_sequences,
        "reorder_observed": reorder_observed,
        "persistent_dedupe": {
            "key": DEDUPE_KEY,
            "applied_state_transitions": applied_by_key.get(DEDUPE_KEY, 0),
            "result_reuse_count": result_reuse_by_key.get(DEDUPE_KEY, 0),
            "input_fingerprint": stored_entry["input_fingerprint"],
            "stored_entry": stored_entry,
            "first_result": stored_entry["result"],
            "reused_result": reused_result_by_key.get(DEDUPE_KEY),
            "state_fingerprint_result_atomic": (
                state_fingerprint_result_atomic
            ),
            "survived_recovery_generation": volatile_generation,
        },
        "conflicts": conflicts,
        "final_state": state["order-42"],
        "recovery": {
            "tick": recovery_tick,
            "deadline_tick": RECOVERY_DEADLINE_TICK,
            "outcome": recovery_outcome,
            "released_message_count": released_message_count,
            "residual_message_count": (
                len(buffered) - released_message_count
            ),
            "buffer_drained": released_message_count == len(buffered),
        },
    }

first = simulate(SEED)
second = simulate(SEED)
long_partition = simulate(SEED, partition_end_delay=5)
conflicting_retry = simulate(SEED, conflicting_retry=True)
assert len(conflicting_retry["conflicts"]) == 1
same_key_different_payload = conflicting_retry["conflicts"][0]

# These invariants make partition handling part of the executable contract,
# instead of trusting a trace label that could survive disabled behavior.
assert first == second
assert first["partition_started"], (
    "partition-causal-invariant: partition start must activate buffering"
)
assert first["buffered_message_count"] == 3
assert first["reorder_observed"]
assert first["persistent_dedupe"]["applied_state_transitions"] == 1
assert first["persistent_dedupe"]["result_reuse_count"] == 1
assert (
    first["persistent_dedupe"]["first_result"]
    == first["persistent_dedupe"]["reused_result"]
)
assert first["persistent_dedupe"]["state_fingerprint_result_atomic"]
assert first["final_state"] == "confirmed"
assert first["recovery"]["outcome"] == "recovered-before-deadline"
assert long_partition["recovery"]["outcome"] == "recovery-deadline-exceeded"
assert first["recovery"]["outcome"] != long_partition["recovery"]["outcome"]
assert same_key_different_payload["error_type"] == "IdempotencyConflict"
assert same_key_different_payload["code"] == "same-key-different-input"
assert same_key_different_payload["rejected"]
assert (
    same_key_different_payload["attempted_input_fingerprint"]
    != same_key_different_payload["stored_input_fingerprint"]
)
assert same_key_different_payload["applied_state_transitions"] == 1
assert same_key_different_payload["stored_entry_unchanged"]
assert conflicting_retry["final_state"] == "confirmed"

report = {
    "seed": SEED,
    "fixture_metadata": {
        "kind": "simulated",
        "provenance": "lesson-authored deterministic partial-failure fixture",
        "limitations": [
            "not a formal proof or production network distribution",
            "does not model Byzantine faults, disk loss, or multi-leader consensus",
            "tick duration and recovery deadline are pedagogical assumptions",
        ],
    },
    "model_assumptions": [
        "the network may delay, duplicate, and reorder messages",
        "a partition buffers messages until recovery in this bounded model",
        "dedupe keys and results survive volatile process recovery",
        "one order state machine rejects an invalid transition",
    ],
    "replay": {
        "identical": first["trace"] == second["trace"],
        "first_trace": first["trace"],
        "second_trace": second["trace"],
    },
    "reorder_observed": first["reorder_observed"],
    "persistent_dedupe": first["persistent_dedupe"],
    "same_key_different_payload": same_key_different_payload,
    "idempotent_state_transition": (
        first["persistent_dedupe"]["applied_state_transitions"] == 1
        and first["final_state"] == "confirmed"
    ),
    "recovery": first["recovery"],
    "partition_mutation": {
        "changed_assumption": "network-partition",
        "description": "partition end moves from tick 6 to tick 11",
        "detected": (
            first["recovery"]["outcome"]
            != long_partition["recovery"]["outcome"]
        ),
        "baseline_outcome": first["recovery"]["outcome"],
        "mutated_outcome": long_partition["recovery"]["outcome"],
        "dedupe_preserved": (
            long_partition["persistent_dedupe"]["applied_state_transitions"]
            == 1
        ),
        "residual_messages": long_partition["recovery"]["residual_message_count"],
        "reconciliation": "surface uncertain completion until replay and state comparison finish",
    },
    "scope": {
        "flp_is_asynchronous_consensus_scope": True,
        "flp_means_practical_consensus_impossible": False,
        "simulation_is_formal_proof": False,
        "retry_semantics": "at-least-once-with-idempotent-result-reuse",
    },
    "mastery_evidence": {
        "lab_steps": [
            {"step": 1, "evidence": "fixed seed, provenance, limits, and six-event fixture"},
            {"step": 2, "evidence": "buffer trace and logical-versus-delivery sequence comparison"},
            {"step": 3, "evidence": "persistent key, one transition, and reused first result"},
            {"step": 4, "evidence": "two identical traces and recovered baseline outcome"},
            {"step": 5, "evidence": "long-partition deadline outcome and reconciliation update"},
        ],
        "assessments": [
            {"assessment": 1, "evidence": "stable key, atomic result, retention, and duplicate replay"},
            {"assessment": 2, "evidence": "deadline, buffer, retention, stale command, and reconciliation evidence"},
        ],
        "rubric_dimensions": [
            "technical-correctness",
            "judgment",
            "evidence",
            "communication",
        ],
        "transfer": {
            "task": "拠点間ネットワーク分断が長期化する条件へ変え、重複排除と復旧結果を再評価する",
            "changed_assumption": "network-partition",
            "evidence": "moving recovery past the deadline changes outcome while dedupe survives",
        },
    },
    "external_network_used": False,
}

print(json.dumps(report, ensure_ascii=False, sort_keys=True))
PY

トレードオフと失敗モード

  • 誤診: HTTP retryを同じ回数に固定すればexactly-onceになる。反証: response loss後のretryでは同じcommandが複数回届く。stable dedupe keyで状態遷移回数を数え、保存済みresponseがbyte-equivalentに再利用されることをreplayする。
  • 誤診: timeoutしたためserver側の処理は失敗している。反証: commit後にresponseだけ失われたtraceと、request未到達traceはclient観測が同じになりうる。serverの永続result照合なしに再実行しない。
  • 誤診: FLPによりRaftなど実用consensusは動作できない。反証: FLPの完全非同期・停止故障・決定的protocol・終了保証というscopeと、実装が用いるtimeout、leader election、quorum、randomized timingの仮定を分ける。
  • key設計: key範囲が広すぎると異なる入力を誤って同一視し、狭すぎるとretryを重複排除できない。tenant、resource、operation、versionと入力fingerprintを検証する。
  • retention: dedupe entryを早く削除すると遅延retryを新規commandとして実行する。無期限保持は容量とprivacy費用を増やすため、最大retry期間とbusiness重複riskから期限を決める。
  • 順序: transport sequenceだけでbusiness causal orderを推論しない。前提state・versionをcommandに含め、stale transitionをrejectまたはreconcileする。
  • 長期分断: queueが回復後にdrainできても利用者deadlineは既に破られている。buffer件数だけを成功条件にせず、結果の鮮度、不確実性、compensationを確認する。

知識チェック

  1. timeoutだけからrequest未到達とresponse lossを区別できない理由は何か。
  2. at-least-once retryで永続化すべきdedupe key、fingerprint、result、stateの関係を説明する。
  3. 同じcommandのduplicateで「状態が同じ」だけでなく、何を数えて一回適用を確認するか。
  4. logical sequenceとdelivery orderを別に記録することで、どの誤診を防げるか。
  5. FLPの結果がCAPの言い換えでも実用consensus不可能の宣言でもない理由を述べる。
  6. partitionがrecovery deadlineを越えた時、dedupe以外に再評価する項目を四つ挙げる。

出典と次の学習

完全非同期consensusの終了保証はFLP論文、実用的leader・log replicationの設計はRaft論文、HTTP methodとretryの意味はRFC 9110、分散transactional key-value architectureの実例はFoundationDB論文で確認する。完全なtitle、URL、kindはlesson metadataに分離した。

次は「性能・容量設計」で、recovery後のqueue drainがlatency、throughput、安全容量へ与える影響を測る。その後「信頼性・可観測性・SLO」で、利用者から見た不確実な結果、burn rate、alert、runbookへfailure modelを接続する。

復習: 1日後にduplicate result、7日後にreorder trace、30日後にpartition mutation、90日後に実systemのretry・dedupe・recovery contractを同じ観測項目で再評価する。

実践ラボ

注文確定commandを部分障害下で決定的にreplayする

提出成果物: 重複、順序、部分障害を再現する決定的シミュレーション

  1. 固定seed 20260731とsimulated fixtureの由来・限界を記録し、deliver、duplicate、partition start・endのevent列を定義する
  2. logical sequenceと実delivery順を別々に記録し、network分断中のmessageをbufferして順序入替を再現する
  3. stable dedupe keyと永続resultを使い、duplicate commandで状態遷移を再適用せず最初のresultを返す
  4. 同じseedとfixtureを二回replayしてtrace一致を確認し、短い分断後のrecovery outcomeを記録する
  5. 分断終了をrecovery deadline後へ移し、outcome、dedupe保持、残留message、reconciliation判断を再評価する

説明して理解を確かめる

7分で、FLPが完全非同期modelで一つの停止故障がある決定的consensusの終了保証を制限する結果でありCAPの言い換えでも実用consensus不可能の宣言でもないこと、retryはexactly-onceを作らずat-least-once配信をstable dedupeと冪等処理で扱うことを説明する。

アセスメント

  1. 問い: 注文確定APIがtimeoutし、clientが同じcommandを再送した。二重確定を防ぎつつ同じ応答を返すには何を永続化するか。

    期待する証拠: request scopeを含むstable dedupe key、入力fingerprint、状態遷移結果、response、retention、atomic commit、key衝突時のreject

  2. 問い: 拠点間分断が5分から2時間へ延びた。短い分断向けの回復設計の何を再評価するか。

    期待する証拠: recovery deadline、queue上限、dedupe retention、stale command、順序、不変条件、reconciliation、利用者へ返す不確実性、replay trace

別問題へ転用する

拠点間ネットワーク分断が長期化する条件へ変え、重複排除と復旧結果を再評価する

復習スケジュール

  1. 1日後

    timeout後retryで同じresultを返すために永続化する情報を列挙する

  2. 7日後

    FLPのmodel assumptionと実用consensus protocolが追加する仮定を区別する

  3. 30日後

    分断期間を延ばし、recovery deadline、dedupe retention、reconciliationを再評価する

  4. 90日後

    timeout後retryで同じresultを返すために永続化する情報を列挙する

評価ルーブリック

4段階の評価基準
観点未達発展途上熟達卓越
technical-correctnesstimeoutを失敗確定とみなし、retry回数だけを増やすdedupe keyはあるが、永続resultまたは状態遷移とのatomicityがないduplicate、reorder、partition、recoveryで状態遷移一回と同一result再利用を検証するretention、key scope、入力fingerprint、stale command、reconciliationまで不変条件として検証する
judgmentexactly-onceまたは完全な可用性を前提として設計する部分障害を認めるが、利用者結果と復旧deadlineを定義しないsafety、liveness、latency、可用性、運用費を明示し、長期分断の再評価条件を残すbusinessの重複許容度とreconciliation責任をprotocol・product契約へ接続する
evidence一度成功したhappy pathだけを示す障害注入はあるが、seed、event trace、model assumptionが不足する固定fixtureの二回replay、duplicate result、reorder、partition mutationを決定的に示すproduction incidentの匿名traceとchaos rehearsalを同じ不変条件へ接続し、simulationとの差を記録する
communicationFLP、CAP、exactly-onceを標語として混同するprotocolを説明するが、仮定、非保証、利用者影響が曖昧であるfailure model、保証範囲、retry semantics、回復結果、残留riskを分けて説明する開発、運用、product担当がfailure budgetと利用者契約を一つのtraceから合意できる

出典

以下の外部資料は利用者が選択したときだけ開きます。