data-scale · Stage 3
重複・順序・分断を再現し復旧境界を設計する
決定的event simulatorでat-least-once配信、順序入替、network分断、回復を再現し、永続dedupeと冪等な状態遷移を検証する。
到達目標
delay、duplicate、reorder、network partition、recoveryを、model assumptionと観測可能なevent traceへ分解できる
- 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace
- FLPの非同期consensus範囲、実用protocolの仮定、retryとexactly-onceの違いを説明する7分間の解説
stableなdedupe key、永続結果、許可された状態遷移を結び、at-least-once retryで副作用を一度だけ適用して結果を再利用できる
- 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace
- message deliveryと状態遷移を分け、duplicate、reorder、長期分断で不変条件と復旧結果を反証する判断記録
分断期間を変更してreplayし、回復deadline、残留message、dedupe保持、利用者へ返す結果を再評価できる
- message deliveryと状態遷移を分け、duplicate、reorder、長期分断で不変条件と復旧結果を反証する判断記録
- 拠点間network分断を長期化し、dedupe retention、replay、reconciliation、利用者結果を更新した障害分析
能力の進行
recognize
message loss、delay、duplicate、reorder、partition、process recoveryを異なるfailure modeとして識別できる
証拠: 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace
explain
FLPが扱う非同期consensusのscopeと、timeout・leader・quorumを使う実用protocolの仮定を区別して説明できる
証拠: FLPの非同期consensus範囲、実用protocolの仮定、retryとexactly-onceの違いを説明する7分間の解説
apply
stable dedupe key、永続結果、冪等な状態遷移によりat-least-once retryを安全に処理できる
証拠: 固定seedで重複、順序入替、分断、回復を再現し、永続dedupeと結果再利用を検証する決定的event trace
diagnose
同じ固定seedでevent traceをreplayし、duplicate effect、順序依存、分断中の不確実な結果を切り分けられる
証拠: message deliveryと状態遷移を分け、duplicate、reorder、長期分断で不変条件と復旧結果を反証する判断記録
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と一緒に読む。
| 方針 | safety | liveness・latency | 必要な証拠 | 主な残留risk |
|---|---|---|---|---|
| 無条件retry | duplicate effectを防げない | 短い障害では進みやすい | 副作用回数と重複率 | 二重課金・二重確定 |
| 永続dedupe・result再利用 | 同じkeyの状態遷移を一度に制限 | store lookupとretentionが必要 | atomic commit、key scope、duplicate replay | key衝突・期限切れ後の再送 |
| 分断中保留・回復後replay | 矛盾した更新を抑制 | 分断期間だけ結果が遅れる | queue上限、順序、不変条件、recovery deadline | 長期分断・stale command |
| 両側受付・後でreconcile | conflict 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で仮定を置き換える。
response lossと再配送があっても副作用を一度だけ進めるdedupeの耐久境界はどこか。
- 重複排除
stable keyと入力fingerprintで再配送を識別する。
- receive
tenant、resource、operationを含むstable keyと入力fingerprintを受け取る。
順序: 0
- lookup
永続dedupe storeにkeyがあれば入力fingerprintを照合し、一致時だけ状態遷移を再実行せず最初のresultを再利用する。不一致はkey衝突として拒否する。
順序: 1
- receive
- 耐久化
状態遷移と再利用resultを同じ境界で保存する。
- apply
keyがなければ現在stateから許可された次stateへ一度だけ遷移する。
順序: 2
- commit
state、fingerprint、resultを同じdurability境界で保存する。response lossはcommitを取り消さない。
順序: 3
- apply
- 回復
partition後の順序差と期限超過を再評価する。
- recover
partition中のmessageを再開後に処理し、logical sequenceとdelivery orderの差を検査する。
順序: 4
- re-evaluate
partitionがdeadlineを越えたら、queue、retention、stale command、reconciliation、利用者結果を更新する。
順序: 5
- recover
- 固定6 event log
seed 20260731の同じ完全logをduplicate、reorder、partition、recoveryの各観点で読む。
- 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
- e2 partition start
tick=2、kind=partition_start。partitionをactiveにし、e1の永続commitを保持したまま以後のmessageをbufferする。
順序: 7
- e3 status read
tick=3、kind=deliver、logical_sequence=4、delivery_priority=1。partition中なのでstatus readはapplyせずbufferし、利用者結果を未確定のまま保つ。
順序: 8
- e4 confirm retry
tick=4、kind=duplicate、logical_sequence=2、delivery_priority=2。同じkeyとfingerprintのretryをbufferし、回復時もeffectを再applyせず保存済みresultを再利用する。
順序: 9
- e5 reconcile read
tick=5、kind=deliver、logical_sequence=3、delivery_priority=0。partition中にbufferし、回復時はe3のsequence=4より先にgapを埋めるread-only resultを得る。
順序: 10
- e6 partition end
tick=6、kind=partition_end。deadline=8以内にrecoveryし、priority順e5→e3→e4で解放する。最終state=confirmed、apply=1、result reuse=1として順序差と残留messageを再評価する。
順序: 11
- e1 confirm
stable keyとfingerprintの照合、stateとresultのatomic commit、partition回復後の再評価を説明できる。
| パラメータ | 選択肢 | 既定値 |
|---|---|---|
| event traceの観測点 | duplicate delivery、reordered delivery、partition中のbuffer、tick 6で回復 | duplicate |
- e1 deliver・apply・commit: e1-confirmをimmediate処理し、pending→confirmedを1回applyしてresult=confirmed-onceをatomic commitする。; 条件 常時; node
e1-confirm; edge なし - partition lens: e2〜e5: partition lens: e2〜e5でpartition active。e3・e4・e5の3 messageをbufferしbuffer=3、e1 commitを保持する。; 条件 常時; node
e2-partition-start、e3-status-read、e4-confirm-retry、e5-reconcile-read; edge なし - e3 status readをbuffer: e3はsequence=4・priority=1。partition中なのでapplyせず、利用者結果を未確定のままbufferする。; 条件 常時; node
e3-status-read; edge なし - duplicate lens: e4: duplicate lens: e4 retryは保存済みresultを再利用してresult reuse=1、effect=1のまま再applyしない。; 条件 常時; node
e4-confirm-retry; edge なし - reorder lens: e5: reorder lens: e5がsequence gapを埋め、recovery release=e5→e3→e4としてdelivery順との差を示す。; 条件 常時; node
e5-reconcile-read; edge なし - recovery lens: e6: recovery lens: e6はtick=6、deadline=8以内にe5→e3→e4を解放し、final=confirmed、apply=1へ収束する。; 条件 常時; node
e6-partition-end; edge なし
| イベント | 開始 | 終了 | 条件 |
|---|---|---|---|
| parameter-change | event-log-start | duplicate-received | event-case=duplicate |
| parameter-change | event-log-start | reorder-gap-filled | event-case=reorder |
| parameter-change | event-log-start | partition-detected | event-case=partition |
| parameter-change | event-log-start | recovery-converged | event-case=recovery |
| next | event-log-start | partition-detected | 常時 |
| timer | event-log-start | partition-detected | 常時 |
| previous | partition-detected | event-log-start | 常時 |
| reset | partition-detected | event-log-start | 常時 |
| next | partition-detected | partition-buffered | 常時 |
| timer | partition-detected | partition-buffered | 常時 |
| previous | partition-buffered | partition-detected | 常時 |
| reset | partition-buffered | event-log-start | 常時 |
| next | partition-buffered | duplicate-received | 常時 |
| timer | partition-buffered | duplicate-received | 常時 |
| previous | duplicate-received | partition-buffered | 常時 |
| reset | duplicate-received | event-log-start | 常時 |
| next | duplicate-received | reorder-gap-filled | 常時 |
| timer | duplicate-received | reorder-gap-filled | 常時 |
| previous | reorder-gap-filled | duplicate-received | 常時 |
| reset | reorder-gap-filled | event-log-start | 常時 |
| next | reorder-gap-filled | recovery-converged | 常時 |
| timer | reorder-gap-filled | recovery-converged | 常時 |
| previous | recovery-converged | reorder-gap-filled | 常時 |
| reset | recovery-converged | event-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-42のpending→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を確認する。
知識チェック
- timeoutだけからrequest未到達とresponse lossを区別できない理由は何か。
- at-least-once retryで永続化すべきdedupe key、fingerprint、result、stateの関係を説明する。
- 同じcommandのduplicateで「状態が同じ」だけでなく、何を数えて一回適用を確認するか。
- logical sequenceとdelivery orderを別に記録することで、どの誤診を防げるか。
- FLPの結果がCAPの言い換えでも実用consensus不可能の宣言でもない理由を述べる。
- 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する
提出成果物: 重複、順序、部分障害を再現する決定的シミュレーション
- 固定seed 20260731とsimulated fixtureの由来・限界を記録し、deliver、duplicate、partition start・endのevent列を定義する
- logical sequenceと実delivery順を別々に記録し、network分断中のmessageをbufferして順序入替を再現する
- stable dedupe keyと永続resultを使い、duplicate commandで状態遷移を再適用せず最初のresultを返す
- 同じseedとfixtureを二回replayしてtrace一致を確認し、短い分断後のrecovery outcomeを記録する
- 分断終了をrecovery deadline後へ移し、outcome、dedupe保持、残留message、reconciliation判断を再評価する
説明して理解を確かめる
7分で、FLPが完全非同期modelで一つの停止故障がある決定的consensusの終了保証を制限する結果でありCAPの言い換えでも実用consensus不可能の宣言でもないこと、retryはexactly-onceを作らずat-least-once配信をstable dedupeと冪等処理で扱うことを説明する。
アセスメント
問い: 注文確定APIがtimeoutし、clientが同じcommandを再送した。二重確定を防ぎつつ同じ応答を返すには何を永続化するか。
期待する証拠: request scopeを含むstable dedupe key、入力fingerprint、状態遷移結果、response、retention、atomic commit、key衝突時のreject
問い: 拠点間分断が5分から2時間へ延びた。短い分断向けの回復設計の何を再評価するか。
期待する証拠: recovery deadline、queue上限、dedupe retention、stale command、順序、不変条件、reconciliation、利用者へ返す不確実性、replay trace
別問題へ転用する
拠点間ネットワーク分断が長期化する条件へ変え、重複排除と復旧結果を再評価する
復習スケジュール
- 1日後
timeout後retryで同じresultを返すために永続化する情報を列挙する
- 7日後
FLPのmodel assumptionと実用consensus protocolが追加する仮定を区別する
- 30日後
分断期間を延ばし、recovery deadline、dedupe retention、reconciliationを再評価する
- 90日後
timeout後retryで同じresultを返すために永続化する情報を列挙する
評価ルーブリック
| 観点 | 未達 | 発展途上 | 熟達 | 卓越 |
|---|---|---|---|---|
| technical-correctness | timeoutを失敗確定とみなし、retry回数だけを増やす | dedupe keyはあるが、永続resultまたは状態遷移とのatomicityがない | duplicate、reorder、partition、recoveryで状態遷移一回と同一result再利用を検証する | retention、key scope、入力fingerprint、stale command、reconciliationまで不変条件として検証する |
| judgment | exactly-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との差を記録する |
| communication | FLP、CAP、exactly-onceを標語として混同する | protocolを説明するが、仮定、非保証、利用者影響が曖昧である | failure model、保証範囲、retry semantics、回復結果、残留riskを分けて説明する | 開発、運用、product担当がfailure budgetと利用者契約を一つのtraceから合意できる |
出典
以下の外部資料は利用者が選択したときだけ開きます。
- Impossibility of Distributed Consensus with One Faulty Process (peer-reviewed)
- In Search of an Understandable Consensus Algorithm (primary)
- RFC 9110: HTTP Semantics (standard)
- FoundationDB: A Distributed Unbundled Transactional Key Value Store (peer-reviewed)