Skip to content

fix(state-node): 同期でバージョンが失われるのを修正 - #70

Merged
somasekimoto merged 3 commits into
mainfrom
fix/crdt-sync-version-loss
Sep 1, 2026
Merged

somasekimoto merged 3 commits into
mainfrom
fix/crdt-sync-version-loss

Conversation

@somasekimoto

@somasekimoto somasekimoto commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

問題

同期は各 operation に「それが生成した DAG ノードの timestamp」を付けて送る(受信側はそこから CID を再計算するため)。この対応付けが timestamp の近さによる推測で、2つの不具合を起こしていた。

1. バージョンが消える — get_operations は「±1秒以内の最初のノード」に対応付けていた。同じ秒に書かれた operation は全て genesis にマッチするため、v2 と v3 が同一 timestamp で送られ、受信側で同じ CID に潰れて新しい方が失われる。ノード間で latest_version が食い違っていた原因。

2. 再配信がエラー扱い — pull 型同期は持っているものを毎回送り直すので、定常状態では大半が既知の operation。再 commit すると同じ CID が再構築され DAG が cycle として拒否する。本番 node1 で 25分に1048件、CPU ほぼ100%張り付き。

Failed to apply operation: graph error: cycle detected in graph

since_version の解決も同じ推測をしており、差分同期がバージョンを再送・欠落させ得た。

修正

timestamp ではなく位置で対応付ける。linear_history は genesis→head 順、operation リストは timestamp 順なので N番目同士が対応する。since_version も同じ履歴上の位置で解決。既知の operation は operation id(作成者が付与し operation と共に伝播する)で判定してスキップする。

この位置対応が成立するのは履歴が一直線のときだけ(get_operations_with_index は全 operation を返すが、linear_history は分岐点で1本しか選ばない)。DAG が分岐すると長さが食い違い、無関係なノードの timestamp を割り当ててしまう — 本PRが直したのと同じ破損が別経路で起きる。レビュー指摘を受け、長さ不一致とノード取得失敗をエラーにした。同期は再試行されるが、黙って誤った stamp を付けたものは再試行されないため。

復旧について

既存ストアの削除は不要。 旧実装で壊れたレプリカは、どちらの壊れ方でも通常の同期で追いつく。

  • 履歴が途中までしかない場合 → a_receiver_with_a_truncated_history_catches_up
  • 誤った CID でノードをコミット済みの場合 → a_replica_that_committed_a_wrong_cid_heals_from_an_ordinary_sync

後者はレビューで指摘を受けて対応した。当初は前者しか検証しておらず、「削除不要」の主張が過大だった。operation id を知っているだけではスキップせず、その id が示すノードが実在することも条件にしている(ノード CID は operation から再計算できる)。

検証

409テスト green、clippy 警告なし。新規テスト8本は全て、対応する修正を外すと落ちることを個別に確認済み。

ローカル4ノードの A/B も試したが、1巡目は baseline 8/9/16件・修正版0件、2巡目は両方0件とばらついた(member 選出と同期タイミング依存)。根拠はテスト側に置いている。

このPRの範囲外

本番ノードは create 中に数十秒フリーズし、ALB ヘルスチェックが落ちて ECS がタスクを入れ替える。これがゲートウェイ経由の POST /content が 503 になる直接の原因で、swarm loop のブロッキングという別の問題。本PRはエラーループと CPU 負荷を取り除くが、これだけで create が安定するわけではない。

🤖 Generated with Claude Code

https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ

somasekimoto and others added 2 commits August 29, 2026 21:30
A sync stamps each operation with the timestamp of the DAG node it
produced, because the receiver recomputes node CIDs from it. Both sides
of that pairing were guesses based on timestamp proximity:

- `get_operations` matched an operation to "the first node within ±1s",
  which is the genesis for every operation written in the same second.
  Two versions therefore shipped under one timestamp, collapsed onto a
  single CID at the receiver, and the newer one was lost.
- `since_version` resolved to "the last operation at or before this
  node's timestamp", indistinguishable between versions in that same
  second, so an incremental catch-up could re-send or skip a version.

Both are now resolved by position: `linear_history` is ordered genesis →
head and the operation list is ordered by timestamp, so entry N in one is
entry N in the other.

Re-delivery is also no longer reported as a failure. A pull-based sync
re-sends what it has, so most operations in a steady-state cluster are
already held; committing one again rebuilds the same node CID, which the
DAG reports as a cycle. Deployed node1 logged that 1048 times in 25
minutes while pinning a core at ~100%. Operations we already hold are now
skipped by operation id.

A replica left behind by the old pairing converges from an ordinary sync,
so no store needs to be cleared to recover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
The single re-delivery case did not show the cost the deployed cluster
paid: node1 logged 1048 apply failures in 25 minutes, each a full commit
attempt that walks the DAG before failing, while pinning a core at ~100%.
This drives 20 sync rounds from 2 providers and asserts every round
accounts for all operations, so a regression shows up as a shortfall
rather than as log noise nobody reads.

Verified discriminating: removing the dedup check fails it (1 of 3
applied on the second round).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
Review found two ways the previous commit fell short.

**Damaged replicas did not heal.** A receiver that committed a node under
a wrong CID — the actual damage the old ±1s pairing did — has the
operation id recorded in state, so skipping on the id alone left the
wrong node in place forever. The claim that no store needs clearing was
therefore wrong for the case that matters most. Skipping now also
requires the node itself to be present under the CID the stamp implies,
which is recomputable from the operation. A replica damaged this way
converges from an ordinary sync; `a_replica_that_committed_a_wrong_cid_
heals_from_an_ordinary_sync` fails without the check.

**The positional pairing had no guard.** It holds only while the history
is a line: `get_operations_with_index` returns every operation for the
genesis, while `linear_history` picks one child per branch. Once the DAG
forks the lists differ, and an index pairs an operation with an unrelated
node — the same wrong-stamp corruption arriving through another door. A
node that fails to load was also silently dropped, shifting every pair
after it. Both now fail the call: a refused sync round is retried, a
silently mis-stamped one is not.

Also documents what `apply_operations` counts, since re-delivery makes
the return value a coverage measure rather than a write count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
@Yu-da-1

Yu-da-1 commented Sep 1, 2026

Copy link
Copy Markdown
Member

レビューで出た既知制限を #72 に切り出しました。

#70 の位置対応は線形履歴でのバージョン喪失と再送 cycle を止める。一方、複数 member への競合書き込みで DAG が分岐すると get_operations が失敗し、マージ後も戻らない。こちらは本 PR の範囲外として follow-up にしています。

@somasekimoto
somasekimoto merged commit 2385cce into main Sep 1, 2026
5 checks passed
somasekimoto added a commit that referenced this pull request Sep 2, 2026
The verified read let you pick any version from a dropdown, and every
choice other than the newest failed:

    decrypted content does not match local_content_id ... pass the local
    content id that corresponds to the version being read (HTTP 409)

The check re-derives the plaintext and compares it against the local
content id, and each version has its own. The registry keeps only the
current one, so the UI has no way to name an older version's id — the
dropdown offered choices it could not honour, and the control looked
broken rather than honest. It now reads the newest version only.

Two e2e expectations were also wrong rather than the code:

- A create writes two versions, not one (the content, then the owner's
  access policy). The suite expected one because versions written in the
  same second used to collapse onto a single CID — the sync bug fixed in
  #70. With that fixed, two is the correct count.
- The pasted-key step read `public_key` off the gateway reply directly,
  but every reply is wrapped in the SDK envelope, so the key is under
  `data` and `fill()` received undefined.

Verified against the deployed cluster: J-1 and J-3 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zvxAj1nZ6H1TDnkRtxnaQ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants