From fb0da91196d0e8c5ebad726e75917ca419cb92dd Mon Sep 17 00:00:00 2001 From: pingqiu Date: Fri, 3 Apr 2026 16:11:38 -0700 Subject: [PATCH] feat: start Phase 14 V2 core shell Make the first V2 core owner explicit in sw-block by freezing Phase 14 docs, mode/readiness/publication semantics, and bounded command emission rules. This turns accepted Phase 13 constraints into executable core behavior without overclaiming live runtime cutover. Made-with: Cursor --- sw-block/.private/phase/phase-14-log.md | 352 +++++++++++++ sw-block/.private/phase/phase-14.md | 180 +++++++ sw-block/design/v2-phase-development-plan.md | 372 ++++++++++--- .../v2-phase14plus-semantic-framework.md | 319 +++++++++++ sw-block/design/v2_mini_core_design.md | 497 +++++++++++++++++- sw-block/engine/replication/command.go | 42 ++ sw-block/engine/replication/doc.go | 24 +- sw-block/engine/replication/engine.go | 342 ++++++++++++ sw-block/engine/replication/event.go | 94 ++++ .../replication/phase14_command_test.go | 124 +++++ .../engine/replication/phase14_core_test.go | 217 ++++++++ sw-block/engine/replication/projection.go | 17 + sw-block/engine/replication/state.go | 121 +++++ 13 files changed, 2620 insertions(+), 81 deletions(-) create mode 100644 sw-block/.private/phase/phase-14-log.md create mode 100644 sw-block/.private/phase/phase-14.md create mode 100644 sw-block/design/v2-phase14plus-semantic-framework.md create mode 100644 sw-block/engine/replication/command.go create mode 100644 sw-block/engine/replication/engine.go create mode 100644 sw-block/engine/replication/event.go create mode 100644 sw-block/engine/replication/phase14_command_test.go create mode 100644 sw-block/engine/replication/phase14_core_test.go create mode 100644 sw-block/engine/replication/projection.go create mode 100644 sw-block/engine/replication/state.go diff --git a/sw-block/.private/phase/phase-14-log.md b/sw-block/.private/phase/phase-14-log.md new file mode 100644 index 000000000..ecb3970b7 --- /dev/null +++ b/sw-block/.private/phase/phase-14-log.md @@ -0,0 +1,352 @@ +Purpose: append-only technical pack and delivery log for `Phase 14` V2 core +extraction. + +--- + +### `14A` Technical Pack + +Date: 2026-04-03 +Goal: freeze the first explicit `V2 core` shell inside +`sw-block/engine/replication` so current accepted semantic constraints become +executable state/event/command/projection ownership, not only design wording + +#### Layer 1: Semantic Core + +##### Problem statement + +`Phase 13` accepted: + +1. bounded replication correctness +2. bounded assignment/publication closure +3. bounded mode normalization + +But those results are still interpreted mainly as: + +1. constrained-`V1` runtime behavior under `V2` rules + +`14A` accepts one narrower thing: + +1. the first real `V2 core` semantic shell exists as code in + `sw-block/engine/replication` + +It does not accept: + +1. live runtime cutover +2. adapter rebinding +3. product-surface migration +4. launch or performance claims + +##### State / contract + +`14A` must make these truths explicit in code: + +1. one bounded `VolumeState` owns normalized mode, readiness, boundary, and + desired replica truth +2. one bounded event set expresses assignment, readiness observation, durable + boundary change, and rebuild escalation +3. one bounded command set expresses semantic decisions without runtime side + effects +4. one bounded projection expresses outward publication meaning from the same + state owner +5. the current interpretation remains: + - explicit `V2 core` shell exists + - integrated runtime authority is still `constrained_v1` until later phases + +##### Must preserve + +1. stable `ReplicaID` ownership +2. durable boundary truth is not inferred from diagnostic shipped progress +3. `publish_healthy` requires named readiness plus durable boundary closure +4. `degraded` and `needs_rebuild` remain distinct fail-closed modes +5. the code does not overclaim live `V2` runtime ownership + +##### Reject shapes + +Reject `14A` if: + +1. the new core shell is only a naming wrapper with no deterministic state + update path +2. `publish_healthy` can be reached from assignment or transport convenience + without durable boundary truth +3. diagnostic sender progress is allowed to establish durable authority +4. `degraded` and `needs_rebuild` collapse into one ambiguous unhealthy bucket +5. the delivery wording implies live path cutover + +#### Layer 2: Execution Core + +##### Files in scope + +Primary files: + +1. `sw-block/engine/replication/state.go` +2. `sw-block/engine/replication/event.go` +3. `sw-block/engine/replication/command.go` +4. `sw-block/engine/replication/projection.go` +5. `sw-block/engine/replication/engine.go` +6. `sw-block/engine/replication/phase14_core_test.go` +7. `sw-block/engine/replication/doc.go` + +Existing substrate kept in place: + +1. `sw-block/engine/replication/registry.go` +2. `sw-block/engine/replication/sender.go` +3. `sw-block/engine/replication/session.go` +4. `sw-block/engine/replication/orchestrator.go` +5. nearby ownership/recovery tests + +##### Execution order + +`14A` follows the `Phase 14+` framework strictly: + +1. explicit state +2. explicit events +3. explicit commands +4. explicit projection +5. deterministic engine loop +6. bounded structural tests + +##### Acceptance basis + +Keep the proof set small and structural: + +1. identity / ownership + - stable `ReplicaID` + - endpoint change invalidates active ownership session +2. state eligibility + - only eligible primary path can reach `publish_healthy` +3. durable boundary + - barrier durability updates authority + - diagnostic shipped progress stays diagnostic +4. fail-closed modes + - `degraded` and `needs_rebuild` stay distinct and non-healthy +5. interpretation rule + - the shell begins `V2 core` + - it does not yet claim live runtime authority + +##### Delivery posture + +This phase uses the larger-slice execution model: + +1. main developer owns semantic design and implementation +2. `sw` is used only for bounded support tasks if needed +3. `tester` validates the structural acceptance basis +4. `manager` challenges semantic adequacy and overclaim control + +##### Review gate + +Every `14A` code change or acceptance note should answer: + +1. semantic constraint satisfied +2. overclaim avoided +3. accepted proof preserved + +#### Starting point inventory + +Current explicit shell already present in repo: + +1. `state.go` + - `RuntimeAuthority` + - `VolumeRole` + - `ModeName` + - `ReadinessView` + - `BoundaryView` + - `ModeView` + - `VolumeState` +2. `event.go` + - assignment + - readiness observation + - barrier accepted / rejected + - checkpoint advance + - rebuild observation / commit +3. `command.go` + - `ApplyRoleCommand` + - `StartReceiverCommand` + - `ConfigureShipperCommand` + - `InvalidateSessionCommand` + - `PublishProjectionCommand` +4. `projection.go` + - `PublicationProjection` +5. `engine.go` + - deterministic `ApplyEvent()` + - recompute mode/readiness/publication + - emit bounded commands and projection +6. `phase14_core_test.go` + - structural acceptance basis for the shell + +#### Immediate development target + +The next development target under `14A` is not to broaden the shell. + +It is to make the shell the clear semantic owner for the first complete chain: + +1. `mode` +2. `readiness` +3. `publication` + +and verify the package stays internally coherent before `14B` begins. + +#### Verification status + +Current package verification on 2026-04-03: + +1. `go test ./...` in `sw-block/engine/replication` +2. result: `PASS` +3. interpretation: + - the current explicit shell is a valid starting point for `Phase 14` + - this verifies bounded internal coherence only + - this does not claim live runtime cutover + +--- + +### `14A` Delivery Note Rev 1 + +Date: 2026-04-03 +Scope: strengthen the first `mode -> readiness -> publication` chain inside the +explicit `V2 core` shell without adding any live adapter hook + +What changed: + +1. publication is now explicit core-owned state, not only an implicit boolean + threaded through readiness/projection +2. the engine now emits normalized publication-gate reasons for bootstrap and + non-primary states +3. `RF=1 / no replicas -> allocated_only` is now frozen directly in the core + shell, aligning the code with accepted `CP13-9` semantics + +Files changed: + +1. `sw-block/engine/replication/state.go` + - added `PublicationView` + - `VolumeState` now owns publication truth explicitly +2. `sw-block/engine/replication/projection.go` + - `PublicationProjection` now carries explicit publication state +3. `sw-block/engine/replication/engine.go` + - split publication recompute away from raw readiness bits + - added explicit gate reasons: + - `awaiting_role_apply` + - `awaiting_shipper_configured` + - `awaiting_shipper_connected` + - `awaiting_barrier_durability` + - `replica_not_primary` + - `allocated_only` + - enforced `no replicas => allocated_only` +4. `sw-block/engine/replication/phase14_core_test.go` + - strengthened the primary publication chain proof with gate-reason checks + - strengthened replica-ready proof with non-primary publication reason + - added direct `allocated_only` proof for no-replica path + +Proofs added or strengthened: + +1. primary publication closure proof + - assignment -> role applied -> shipper configured -> shipper connected -> + barrier durability now produces the expected gate reason at each stage +2. replica-ready is not publication proof + - `replica_ready` stays non-healthy with explicit reason + `replica_not_primary` +3. `CP13-9` allocated-only proof + - a primary assignment with no replicas remains `allocated_only`, not + `bootstrap_pending` + +Validation: + +1. `gofmt -w state.go projection.go engine.go phase14_core_test.go` +2. `go test ./...` +3. result: `PASS` + +Constraint / overclaim / proof review: + +1. semantic constraint satisfied + - `CP13-8A`: assignment/readiness/publication closure must be explicit + - `CP13-9`: `allocated_only`, `bootstrap_pending`, `replica_ready`, + `publish_healthy`, `degraded`, and `needs_rebuild` must stay bounded and + non-overlapping +2. overclaim avoided + - publication health can no longer be inferred from assignment presence, + shipper connection alone, or replica readiness + - RF=1/no-replica path no longer overclaims `bootstrap_pending` +3. proof preserved + - barrier durability remains the authority for `publish_healthy` + - diagnostic shipped progress remains non-authoritative + - constrained-`V1` runtime interpretation remains explicit + +--- + +### `14B` Delivery Note Rev 1 + +Date: 2026-04-03 +Scope: freeze first bounded command-emission rules so the explicit `V2 core` +decides commands from semantic gaps, not from repeated event convenience + +What changed: + +1. repeated assignments no longer blindly reset semantic state and re-emit the + same commands +2. command emission is now gap-driven: + - apply role only when epoch/role command state is stale + - start receiver only when replica path still needs receiver start for the + current epoch + - configure shipper only when primary path still needs current replica + configuration + - invalidate session only on a new failure transition, not every repeated + degraded event +3. assignment changes still re-emit the needed command when semantic intent + really changes + +Files changed: + +1. `sw-block/engine/replication/state.go` + - added private command-state tracking to `VolumeState` +2. `sw-block/engine/replication/engine.go` + - extracted assignment handling into gap-driven command logic + - preserved readiness when the assignment is repeated without semantic change + - reset only the relevant readiness edges when role/epoch/replica-set changes + - deduplicated repeated invalidation commands for the same failure reason +3. `sw-block/engine/replication/phase14_command_test.go` + - added exact command-sequence proofs + +Proofs added: + +1. primary repeated-assignment boundedness + - first assignment emits: + - `apply_role` + - `configure_shipper` + - `publish_projection` + - repeated identical assignment emits only: + - `publish_projection` +2. replica repeated-assignment boundedness + - first replica assignment emits: + - `apply_role` + - `start_receiver` + - `publish_projection` + - repeated identical assignment emits only: + - `publish_projection` +3. assignment-change selective reissue + - changed replica endpoint on primary path reissues only + `configure_shipper`, not the whole initial command bundle +4. repeated-failure boundedness + - first `BarrierRejected(timeout)` emits `invalidate_session` + - repeated `BarrierRejected(timeout)` does not emit duplicate invalidation + +Validation: + +1. `gofmt -w state.go engine.go phase14_command_test.go` +2. `go test ./...` +3. result: `PASS` + +Constraint / overclaim / proof review: + +1. semantic constraint satisfied + - `Phase 14B`: command emission must come from semantic state, not runtime + convenience + - `CP13-8A`: assignment/readiness/publication closure must stay explicit + - `CP13-9`: bounded mode meaning must not be destabilized by repeated command + churn +2. overclaim avoided + - repeated assignment no longer acts like proof that role apply / receiver + start / shipper configure still need to happen + - repeated failure does not create unbounded invalidation spam that looks like + fresh semantic transitions +3. proof preserved + - `14A` publication-gate proofs still hold + - barrier durability is still the only path to `publish_healthy` + - constrained-`V1` interpretation is still explicit, not broadened diff --git a/sw-block/.private/phase/phase-14.md b/sw-block/.private/phase/phase-14.md new file mode 100644 index 000000000..039d048b0 --- /dev/null +++ b/sw-block/.private/phase/phase-14.md @@ -0,0 +1,180 @@ +# Phase 14 + +Date: 2026-04-03 +Status: active +Purpose: make the `V2 core` explicit inside `sw-block/engine/replication` so +accepted semantic constraints become executable ownership, rather than staying +only as design and constrained-`V1` interpretation + +## Why This Phase Exists + +`Phase 13` accepted a bounded replication-correctness package on the current +chosen path, including: + +1. corrected `sync_all` replication semantics +2. bounded real-workload validation +3. assignment/publication closure +4. bounded mode normalization + +That package matters, but it still mostly evaluates `V1` runtime behavior under +`V2` constraints. + +`Phase 14` exists to change that. + +The new problem is no longer: + +1. keep deepening constrained-`V1` validation as the primary path + +It is: + +1. make `V2 core` an explicit owner inside the repo +2. turn accepted claims into core-owned state, events, commands, and projections +3. create a bounded executable basis for later adapter rebinding + +## Phase Goal + +Build the first real `V2 core` inside `sw-block/engine/replication` as a +deterministic, side-effect-free semantic owner for: + +1. state and transitions +2. command decisions +3. outward projection meaning + +This phase does not yet claim live runtime cutover. + +## Execution Rule + +For all `Phase 14` work, implementation order must be: + +1. define core-owned state and transitions +2. define command-emission rules +3. define projection contracts +4. only then connect adapters in later phases + +Do not invert this order. + +If runtime wiring comes first, `V1` mixed runtime state will silently retake +semantic authority. + +## Execution Model + +This phase uses the new working model: + +1. primary developer + - owns `V2 core` semantic design and implementation + - decides state/transition/command/projection shape +2. `sw` + - supports bounded implementation work after semantic ownership is already + defined + - should receive only narrow, easy-to-accept tasks +3. `tester` + - validates bounded acceptance basis and checks for overclaim +4. `manager` + - performs phase challenge/review gates against semantic discipline + +## Scope + +### In scope + +1. explicit core-owned state in `sw-block/engine/replication` +2. explicit bounded event vocabulary +3. explicit bounded command vocabulary +4. explicit normalized projection vocabulary +5. structural acceptance tests proving accepted constraints can be represented by + the new core + +### Out of scope + +1. no live `weed/` adapter hook yet +2. no product-surface rebinding yet +3. no broad runtime migration +4. no launch or performance claims +5. no reopening accepted `Phase 13` claim boundaries + +## Phase 14 Slices + +### `14A`: Mode / Readiness / Publication Core Closure + +Goal: + +1. make mode, readiness, and publication first-class core-owned meanings + +Acceptance object: + +1. `VolumeState`, event vocabulary, command vocabulary, projection vocabulary, + and deterministic engine loop exist in `sw-block/engine/replication` +2. `publish_healthy` is derived from named semantic state rather than runtime + convenience +3. fail-closed mode distinctions stay explicit: + - `allocated_only` + - `bootstrap_pending` + - `replica_ready` + - `publish_healthy` + - `degraded` + - `needs_rebuild` +4. the structural acceptance tests prove: + - stable identity ownership is preserved + - diagnostic shipped progress does not establish durable truth + - `publish_healthy` requires durable boundary truth + - `degraded` and `needs_rebuild` remain distinct fail-closed meanings + - the current integrated interpretation remains `constrained_v1`, not live + `v2_core` cutover + +Status: + +1. delivered + +### `14B`: Assignment / Command Semantics Closure + +Goal: + +1. make assignment transitions and command emission rules explicit from semantic + state rather than runtime convenience + +Acceptance object: + +1. assignment intent, role application, receiver start, shipper configuration, + and invalidation commands are emitted as bounded semantic decisions +2. one bounded event sequence produces one bounded command sequence +3. command emission does not depend on `weed/` internals + +Status: + +1. active + +### `14C`: Boundary / Recovery Semantic Closure + +Goal: + +1. make durable boundary and recovery semantics explicit in the same core owner + +Acceptance object: + +1. boundary truth distinguishes durable progress, checkpoint truth, and + diagnostic sender progress +2. recovery semantics preserve the accepted constraints around eligibility, + fail-closed degradation, and rebuild escalation +3. structural tests stay bounded and do not claim live path migration yet + +Status: + +1. planned + +## Manager Review Gate + +Every `Phase 14` slice must survive one challenge review that asks: + +1. which semantic constraint does this slice satisfy? +2. which overclaim does this slice prevent? +3. which accepted checkpoint proof does this slice preserve? + +Reject the slice if any of those questions can only be answered by vague runtime +intuition. + +## Immediate Next Step + +Continue with `14B`. + +Use the explicit shell from `14A` as the semantic substrate, then freeze +gap-driven command emission so repeated assignments and repeated failures do not +silently turn back into runtime-convenience command spam. diff --git a/sw-block/design/v2-phase-development-plan.md b/sw-block/design/v2-phase-development-plan.md index 988962c44..fdbae571d 100644 --- a/sw-block/design/v2-phase-development-plan.md +++ b/sw-block/design/v2-phase-development-plan.md @@ -35,35 +35,53 @@ Use these rules for all later phases: ## Current Baseline -Current accepted path through `Phase 09`: +Current accepted path through `Phase 13`, with `Phase 14` now the immediate next engineering focus: 1. protocol/algo truth set is strong -2. engine recovery core is strong -3. real control delivery exists on the chosen path -4. real one-chain catch-up and rebuild closure exist on the chosen path -5. production-grade execution closure is accepted on the chosen path: - - real `TransferFullBase` - - real `TransferSnapshot` - - real `TruncateWAL` - - stronger live runtime ownership on the volume-server path -6. unified hardening validation exists on the chosen path -7. one bounded accepted path exists for: +2. engine recovery core is strong on the chosen path +3. control-plane closure is accepted on the chosen path +4. selected product-surface rebinding is accepted on the chosen path +5. bounded production hardening is accepted on the chosen path +6. one bounded accepted path exists for: - `RF=2` - `sync_all` - existing master / volume-server heartbeat path + - `blockvol` as execution backend +7. `Phase 13` has now closed the bounded `WAL V1.5` contract package for the current constrained chosen path: + - real-workload package accepted + - assignment/publication closure accepted + - bounded mode normalization accepted Phase-accounting note: 1. `Phase 08` is closed -2. `Phase 09` is also closed -3. this roadmap should now be read from the post-`Phase 09` state, not the post-`Phase 08` state +2. `Phase 09` is closed +3. `Phase 10` is closed +4. `Phase 11` is closed +5. `Phase 12` is the accepted hardening baseline for the chosen path +6. `Phase 13` is closed and should be read as one bounded constrained-runtime contract package, not as launch approval +7. `Phase 14` is now the immediate next engineering focus: + - explicit `V2 core` extraction + - not more deepening of constrained-`V1` validation by default + +Important interpretation rule: + +1. the accepted chosen path and claim/evidence set are real +2. the current `weed/` runtime structure is not automatically the final `V2` structure +3. until `Phase 14` establishes an explicit `V2 core`, current integrated tests should be read primarily as: + - `V1` runtime validation under `V2` constraints + - not proof that a completed `V2 runtime` already exists +4. future phases must treat: + - `v2-protocol-claim-and-evidence.md` as current claim authority + - `v2_mini_core_design.md` as engineering-structure authority + - `v2-reuse-replacement-boundary.md` as reuse vs replacement authority This means the next phases should focus mainly on: -1. stronger control-plane closure -2. later product-surface rebinding -3. production hardening -4. bounded cleanup of low-severity residuals without reopening accepted execution semantics +1. extracting the long-term `V2 core` structure explicitly +2. rebinding `weed/` into adapter / projection / backend roles +3. closing one bounded `V2`-native runtime path before productionization +4. using `Phase 13` evidence as acceptance input rather than continuing to treat constrained-`V1` validation as the main workstream ## Phase Roadmap @@ -263,9 +281,11 @@ Current status: 4. `P3` accepted: - acceptance object = bounded diagnosability / blocker accounting / runbook hardening on the chosen path - bounded operator-visible diagnosis surfaces and finite blocker accounting are accepted -5. `P4` active: +5. `P4` accepted: - acceptance object = bounded performance floor / rollout-gate hardening on the chosen path - not broad rollout readiness beyond the named launch envelope +6. `Phase 12` is now closed: + - one bounded hardening package is accepted for the chosen path Current `P4` first delivery shape: @@ -294,23 +314,211 @@ Closed-loop expectation for `P4` review: 4. remaining rollout blockers are explicit and finite 5. claims remain bounded to measured floor / named launch envelope only -After `Phase 12`: - -1. move to a productionization program, not more protocol-discovery phases -2. freeze production blockers and the supported launch envelope -3. run a limited internal pilot with incident-driven hardening -4. perform controlled rollout only after explicit launch-gate review - -### Post-`Phase 12`: Productionization Program +### Phase 13: V1.5 Contract Closure And Contradiction Ledger Goal: -1. turn the accepted `Phase 12` chosen path into a bounded first-launch product envelope without reopening protocol discovery +1. close the bounded `RF=2 sync_all` contract on the current chosen path +2. freeze what `WAL V1.5` is allowed to claim +3. classify remaining live contradictions as: + - reusable-core bug + - adapter-boundary bug + - `V2`-authority bug + +Delivery object: + +1. one frozen `CP13-*` contract package +2. one centralized claim/evidence ledger for the active chosen path +3. one explicit contradiction/rerun queue for invalidated or narrowed evidence + +Closed-loop validation: + +1. protocol/unit/adversarial proofs for `CP13-1..7` +2. bounded real-workload validation, assignment/publication closure, and mode normalization are all accepted +3. explicit narrowing or restoration of claims in the centralized ledger + +Non-claims: + +1. not full `V2 core` extraction +2. not launch approval +3. not proof that current `weed/` structure is the final `V2` structure + +Key files / ownership: + +1. `sw-block/.private/phase/phase-13-*.md` +2. `sw-block/design/v2-protocol-claim-and-evidence.md` +3. `sw-block/design/v2-protocol-truths.md` + +### Phase 14: V2 Core Extraction + +Goal: + +1. make the `V2 core` explicit as a long-term code structure +2. stop relying on implicit semantic ownership spread across runtime files + +Execution rule: + +1. define core-owned state and transitions first +2. freeze command-emission rules second +3. freeze projection contracts third +4. only then connect adapters + +Delivery object: + +1. one explicit `V2 core` package/file layout with named: + - `state` + - `event` + - `command` + - `projection` +2. one minimal real code path for: + - `ApplyEvent()` + - `Decide()` + - `EmitCommands()` +3. one bounded parity package showing accepted prototype/FSM semantics are preserved + +Closed-loop validation: + +1. focused engine tests proving accepted claims can be represented through the new event/command core +2. parity checks against accepted prototype/FSM semantics +3. no-overclaim review that this is structural extraction, not live-path cutover + +Recommended slice order: + +1. `Phase 14A`: core-owned automata + - explicit assignment / recovery / boundary / mode / publication automata + - structural tests only +2. `Phase 14B`: command semantics + - bounded command sequences derived from semantic state + - still no live `weed/` execution +3. `Phase 14C`: projection contracts + - lookup / heartbeat / debug / tester normalization from core-owned state + - surface-consistency tests + +Immediate focus inside `Phase 14`: + +1. start from one complete semantic chain: + - `mode` + - `readiness` + - `publication` +2. use accepted `CP13-8A` and `CP13-9` as the first hard input package + +Non-claims: + +1. not full live-path cutover +2. not replacement of all `weed/` logic +3. not a separate process yet + +Key files / ownership: + +1. `sw-block/design/v2_mini_core_design.md` +2. `sw-block/design/v2-phase14plus-semantic-framework.md` +3. `sw-block/engine/replication/` + +### Phase 15: Adapter And Projection Rebinding + +Goal: + +1. make `weed/` a bounded adapter/projection layer instead of mixed semantic authority +2. close assignment -> readiness -> publication through named `V2` state + +Delivery object: + +1. one explicit adapter-boundary package on the live path +2. one explicit projection store / projection surface package for: + - readiness + - publication + - diagnostics +3. one narrowed role definition where: + - `BlockService` is closer to command executor + - registry is closer to projection store + +Closed-loop validation: + +1. live-path tests proving assignment delivered != receiver ready != publish healthy unless the named readiness/projection loop is closed +2. focused regression package for the `CP13-8A` bug class +3. operator-visible projection checks rather than internal-state-only proof + +Recommended slice order: + +1. `Phase 15A`: minimal adapter hook + - one narrow event path into the core + - one bounded command path back out + - prove no semantic split on that narrow path +2. `Phase 15B`: projection-store rebinding + - registry / lookup / tester-facing surfaces consume core-owned projection truth + - prove assignment delivered != ready != publish healthy on the real path + +Non-claims: + +1. not backend rewrite +2. not broader productization +3. not new transport matrix claims + +Key files / ownership: + +1. `sw-block/design/v2-reuse-replacement-boundary.md` +2. `sw-block/design/v2-phase14plus-semantic-framework.md` +3. `weed/server/volume_server_block.go` +4. `weed/server/block_heartbeat_loop.go` +5. `weed/server/master_block_registry.go` +6. `weed/server/master_block_failover.go` + +### Phase 16: V2-Native Runtime Closure + +Goal: + +1. make the integrated runtime behave as a `V2`-owned recovery/control system +2. stop depending on a merely improved `WAL V1.5` path for correctness interpretation + +Execution precondition: + +1. do not enter `Phase 16` until `Phase 14` has frozen state / command / projection semantics +2. do not treat adapter rebinding alone as runtime closure + +Delivery object: + +1. one bounded product/runtime path where failover, recovery, publication, and selected surfaces are driven by the `V2 core` + adapter contract +2. one explicit runtime integration package that maps simulator/prototype failure classes to live-path behavior + +Closed-loop validation: + +1. end-to-end failover/recovery scenarios on the core-driven path +2. simulator-to-runtime consistency checks for the named failure classes that `V2` is supposed to survive +3. bounded real-workload checks on the core-driven path, not just on the legacy-integrated path + +Non-claims: + +1. not broad rollout approval +2. not physical split into an independent `V2 core process` unless the logic is already structurally independent + +Key files / ownership: + +1. `sw-block/engine/replication/` +2. `weed/server/` +3. selected `weed/storage/blockvol/v2bridge/*` files + +### Cross-Phase Review Rule For `Phase 14+` + +For any new transition, command, or projection rule in `Phase 14+`, require a +short justification in the delivery note or code review: + +1. semantic constraint satisfied + - which `claim / truth / CP13-*` item it is implementing +2. overclaim avoided + - which false healthy / ready / durable / recoverable interpretation is being prevented +3. proof preserved + - which accepted test or checkpoint remains valid because of the rule + +### Productionization Program After `Phase 16` + +Goal: + +1. turn the accepted `Phase 16` bounded path into a bounded first-launch product envelope without reopening protocol discovery or core-ownership questions Program slices: 1. Program `P0`: launch-envelope freeze - - freeze the first supported launch envelope from accepted `P1`-`P4` evidence + - freeze the first supported launch envelope from accepted hardening + runtime-closure evidence - lock: - supported topology / transport matrix - explicit exclusions @@ -338,46 +546,51 @@ Program slices: - widen within the same launch envelope - block expansion - require explicit mapping from any expansion decision back to: - - accepted `Phase 12` evidence + - accepted `Phase 16` evidence - pilot outcomes - incident dispositions - reject if rollout broadens beyond the named envelope or reuses pilot success as generic production proof Cross-cutting rules: -1. do not invent a `Phase 12 P5`; this is a separate productionization program -2. keep the accepted chosen path fixed unless incidents expose a real bug -3. treat known missing evidence as explicit constraints until cleared, especially: +1. do not invent a `Phase 12 P5`; productionization remains separate from hardening +2. do not collapse `Phase 13-16` into generic productionization; they are engineering-structure phases +3. keep the accepted chosen path fixed unless contradiction or incident evidence exposes a real bug +4. treat known missing evidence as explicit constraints until cleared, especially: - failover-under-load performance - hours/days soak under load - `RF>2` - broad transport matrix - full gRPC-stream integration evidence -4. keep V2 post-`Phase 12` direction aligned with the learn-tree close/gate work so the roadmap does not split into contradictory stories +5. keep the roadmap aligned with: + - `v2-protocol-claim-and-evidence.md` + - `v2_mini_core_design.md` + - `v2-reuse-replacement-boundary.md` ## Module Status Map -| Module area | Current status | Current owner phase | Next target phase | Notes | -| ------------------------------------------------------------- | ---------------------------- | ------------------------ | ----------------- | ------------------------------------------------------------------------------------------ | -| `sw-block/engine/replication` core FSM/orchestrator/driver | Strong | `Phase 09` accepted | `Phase 10-12` | Main later work is control-plane/runtime integration and hardening, not new core semantics. | -| Engine executor real I/O boundary (`CatchUpIO` / `RebuildIO`) | Strong on chosen path | `Phase 09` accepted | `Phase 10/12` | Keep the boundary stable; later work is orchestration/control proof and hardening. | -| `weed/storage/blockvol/v2bridge/control.go` | Strong on chosen path | `Phase 08/09/10` accepted | `Phase 12` | Chosen-path control mapping is accepted; later work is hardening, not new mapping semantics. | -| `weed/storage/blockvol/v2bridge/reader.go` | Strong | `Phase 09` accepted | `Phase 12` | Mostly stable; later work is verification/hardening, not new semantics. | -| `weed/storage/blockvol/v2bridge/pinner.go` | Strong | `Phase 09` accepted | `Phase 12` | Retention safety proven; later work is hardening under disturbance. | -| `weed/storage/blockvol/v2bridge/executor.go` WAL scan | Strong | `Phase 09` accepted | `Phase 12` | Real execution path closed on chosen path; later work is hardening. | -| `v2bridge` `TransferFullBase` | Strong on chosen path | `Phase 09 P1` accepted | `Phase 12` | Execution closure accepted; do not reopen casually. | -| `v2bridge` `TransferSnapshot` | Strong on chosen path | `Phase 09 P2` accepted | `Phase 12` | Execution closure accepted; do not reopen casually. | -| `v2bridge` `TruncateWAL` | Strong on chosen path | `Phase 09 P3` accepted | `Phase 12` | Narrow Option A contract accepted; later work is hardening/planning improvement. | -| `weed/server/volume_server_block.go` V2 assignment intake | Strong on chosen path | `Phase 10 P4` accepted | `Phase 11/12` | VS-side ingress, convergence, idempotence, and bounded master-driven closure are accepted; later work is product-surface integration and hardening. | -| `weed/server/block_recovery.go` live runtime ownership | Strong on chosen path | `Phase 09/10` accepted | `Phase 11/12` | Serialized ownership and bounded control-plane integration are accepted; later work is product-surface integration and hardening. | -| `blockvol` WAL/flusher/checkpoint runtime | Reuse reality | Existing production code | `Phase 12` | Reuse implementation; later work is disturbance/restart hardening. | -| `blockvol` rebuild transport/server reality | Reuse with redesign boundary | Existing production code | `Phase 12` | Bounded chosen-path integration is accepted; later work is hardening under disturbance. | -| local server identity (`localServerID`) | Strong on chosen path | `Phase 10 P1` accepted | `Phase 12` | Canonical `volumeServerId` now backs chosen-path local identity; later work is hardening only. | -| Snapshot product path | Strong on chosen path | `Phase 11` accepted | `Phase 12` | Product-visible snapshot create/list/delete closure and restore workflow closure are accepted; later work is hardening, not rebinding. | -| `CSI` integration | Strong on chosen path | `Phase 11` accepted | `Phase 12` | Bounded controller/node lifecycle rebinding accepted; later work is hardening. | -| `NVMe` / `iSCSI` front-ends | Strong on chosen path | `Phase 11` accepted | `Phase 12` | Publication/address truth rebinding accepted; later work is runtime/perf hardening. | -| Testrunner / infra / metrics | Strong support layer | existing | `Phase 11-12` | Reuse to validate later product-surface and hardening phases. | +| Module area | Current status | Current owner phase | Next target phase | Notes | +| ----------- | -------------- | ------------------- | ----------------- | ----- | +| `sw-block/engine/replication` core FSM/orchestrator/driver | Strong long-term `V2 core` asset | `Phase 09` accepted, `Phase 13` active constraints | `Phase 14` | Main next work is explicit `state / event / command / projection` extraction, not reopening accepted semantics. | +| Engine executor real I/O boundary (`CatchUpIO` / `RebuildIO`) | Strong on chosen path | `Phase 09` accepted | `Phase 14/16` | Keep the boundary stable; later work is to connect it to explicit `V2 core` ownership and runtime closure. | +| `weed/storage/blockvol/v2bridge/control.go` | Strong boundary adapter on chosen path | `Phase 08/09/10` accepted | `Phase 15` | Remains a bridge between `V2` truth and runtime execution; should not accumulate new semantic authority casually. | +| `weed/storage/blockvol/v2bridge/reader.go` | Strong backend-facing adapter | `Phase 09` accepted | `Phase 15/16` | Mostly stable; later work is explicit boundary ownership and runtime proof, not new protocol semantics. | +| `weed/storage/blockvol/v2bridge/pinner.go` | Strong backend-facing adapter | `Phase 09` accepted | `Phase 15/16` | Retention safety is proven on the chosen path; later work is keeping it under explicit `V2` control boundaries. | +| `weed/storage/blockvol/v2bridge/executor.go` WAL scan | Strong backend-facing adapter | `Phase 09` accepted | `Phase 15/16` | Real execution path is closed on the chosen path; later work is core-driven runtime integration. | +| `v2bridge` `TransferFullBase` | Strong on chosen path | `Phase 09 P1` accepted | `Phase 16` | Execution closure is accepted; do not reopen casually unless core-driven runtime closure exposes a real contradiction. | +| `v2bridge` `TransferSnapshot` | Strong on chosen path | `Phase 09 P2` accepted | `Phase 16` | Execution closure is accepted; later work is bounded runtime closure rather than first-implementation discovery. | +| `v2bridge` `TruncateWAL` | Strong on chosen path | `Phase 09 P3` accepted | `Phase 16` | Narrow contract is accepted; later work is preserving that contract under explicit runtime ownership. | +| `weed/server/volume_server_block.go` V2 assignment intake | Adapter-boundary reality with accepted chosen-path closure | `Phase 10 P4` accepted, `Phase 13` contradiction pressure | `Phase 15` | This is where assignment/readiness/publication closure must become explicit adapter behavior rather than mixed service semantics. | +| `weed/server/block_recovery.go` live runtime ownership | V2-owned runtime truth on chosen path | `Phase 09/10` accepted | `Phase 15/16` | Serialized ownership is accepted; later work is making it cooperate with explicit `V2 core` and projection boundaries. | +| `weed/server/master_block_registry.go` / failover / handlers | Mixed projection/truth reality | `Phase 10-12` accepted surfaces | `Phase 15` | Should converge toward projection store + operator-visible truth surfaces rather than mixed business-logic/state storage. | +| `blockvol` WAL/flusher/checkpoint runtime | Reuse reality | Existing production code | `Phase 16` | Reuse implementation; do not let `V1` replication semantics redefine `V2` truth. | +| `blockvol` rebuild transport/server reality | Reuse with redesign boundary | Existing production code | `Phase 16` | Bounded chosen-path integration is accepted; later work is runtime closure under `V2` authority. | +| local server identity (`localServerID`) | Strong chosen-path rule with narrowed semantics | `Phase 10 P1` accepted, `Phase 13` constraints | `Phase 15` | Stable identity must remain distinct from transport address shape and explicit in later adapter/projection work. | +| Snapshot product path | Strong on chosen path | `Phase 11` accepted | `Phase 16` | Product-visible snapshot workflow is accepted on the chosen path; later work is preserving it under `V2`-native runtime closure. | +| `CSI` integration | Strong on chosen path | `Phase 11` accepted | `Phase 16` | Bounded controller/node lifecycle rebinding is accepted; later work is runtime preservation, not first rebinding. | +| `NVMe` / `iSCSI` front-ends | Strong on chosen path | `Phase 11` accepted | `Phase 16` | Publication/address truth rebinding is accepted; later work is proving the `V2`-driven runtime path beneath them. | +| Testrunner / infra / metrics | Strong support layer | existing | `Phase 13-16` | Reuse to validate contract closure, adapter contradictions, runtime closure, and later productization gates. | ## Completion-State Targets @@ -385,23 +598,35 @@ Cross-cutting rules: Use these rough targets to judge whether a phase is moving the product meaningfully. -| Phase | Expected completion move | -| ---------- | ----------------------------------------------------------------------------- | +| Phase | Expected completion move | +| ----- | ------------------------ | | `Phase 09` | from validation-grade backend execution to accepted execution closure on the chosen path | | `Phase 10` | from bounded control-entry proof to stronger end-to-end control-plane closure | -| `Phase 11` | from backend-ready path to selected product-surface readiness | -| `Phase 12` | from candidate-safe to production-safe | +| `Phase 11` | from backend-ready path to selected product-surface readiness | +| `Phase 12` | from candidate-safe to production-safe on one bounded chosen path | +| `Phase 13` | from scattered `WAL V1.5` evidence to one frozen contract/contradiction ledger | +| `Phase 14` | from implicit core semantics to explicit `V2 core` package/engine structure | +| `Phase 15` | from mixed runtime semantics to explicit adapter/projection closure | +| `Phase 16` | from improved chosen-path runtime to one bounded `V2`-native runtime path | +| Productionization | from bounded runtime closure to bounded launch-envelope / pilot / rollout review | ## Near-Term Execution Direction -If the goal is to maximize product completion efficiently, the recommended order is: +If the goal is to maximize product completion efficiently, the recommended order is now: -1. keep `Phase 09` closed and do not reopen accepted execution semantics casually -2. keep `Phase 10` closed and do not reopen accepted bounded control-plane closure casually -3. move next to `Phase 11` product surface rebinding -4. then `Phase 12` production hardening -5. then the post-`Phase 12` productionization program: +1. keep `Phase 09-12` accepted closures closed and do not reopen them casually +2. keep `Phase 13` closed as the bounded `WAL V1.5` contract package +3. move next to `Phase 14` core extraction: + - explicit `state / event / command / projection` + - minimal `ApplyEvent() -> Decide() -> EmitCommands()` path +4. then `Phase 15` adapter/projection rebinding: + - assignment + - readiness + - publication + - diagnostics +5. then `Phase 16` bounded `V2`-native runtime closure +6. only then move to the productionization program: - freeze launch envelope - run limited internal pilot - harden from incidents @@ -409,18 +634,21 @@ If the goal is to maximize product completion efficiently, the recommended order The most important near-term engineering weight should now go to: -1. finishing `Phase 12` -2. then the bounded productionization program immediately after it +1. making `sw-block/engine/replication` the explicit long-term `V2 core` +2. keeping `weed/` changes bounded to adapter / projection / backend roles unless they are explicitly promoted into `V2`-owned authority +3. using `CP13-1..9` as acceptance input for new `V2 core` work rather than as a reason to keep extending constrained-`V1` validation ## Short Summary -The V2 line now has accepted execution closure on one bounded chosen path. -The next development plan should treat later work as bounded hardening plus productionization, not more protocol discovery. +The V2 line now has accepted execution, control, product-surface, and hardening closure on one bounded chosen path. -The main heavy engineering work still ahead is: +The next development plan should not jump directly from `Phase 12` to productionization. +It should move through four bounded engineering phases first: -1. stronger end-to-end control-plane closure -2. later product-surface rebinding -3. production hardening -4. post-`Phase 12` launch-envelope freeze, internal pilot, and controlled rollout review +1. `Phase 13`: freeze the current `WAL V1.5` constrained-runtime contract package +2. `Phase 14`: extract the explicit `V2 core` +3. `Phase 15`: rebind `weed/` as adapter/projection reality +4. `Phase 16`: close one bounded `V2`-native runtime path + +Only after that should the roadmap move into bounded launch-envelope freeze, internal pilot, incident-driven hardening, and controlled rollout review. diff --git a/sw-block/design/v2-phase14plus-semantic-framework.md b/sw-block/design/v2-phase14plus-semantic-framework.md new file mode 100644 index 000000000..a35a0aba1 --- /dev/null +++ b/sw-block/design/v2-phase14plus-semantic-framework.md @@ -0,0 +1,319 @@ +# V2 Phase 14+ Semantic-First Framework + +Date: 2026-04-03 +Status: active +Purpose: define the overall `Phase 14+` implementation framework so `V2` +runtime extraction is driven by semantics first: core-owned state and +transitions, then command rules, then projection contracts, and only then +adapter rebinding + +## Why This Document Exists + +`Phase 13` closed one bounded constrained-runtime contract package: + +1. real-workload validation +2. assignment/publication closure +3. bounded mode normalization + +That package is valuable, but it is not yet a completed `V2 runtime`. + +The next problem is therefore no longer: + +1. keep deepening constrained-`V1` validation by default + +It is: + +1. how to turn the accepted semantic constraints into a real `V2 core` +2. how to sequence `Phase 14+` so `V1` mixed runtime state does not silently + regain semantic authority + +## Core Rule + +For `Phase 14+`, implementation order must be: + +1. define core-owned state and transitions +2. define command-emission rules +3. define projection contracts +4. only then connect adapters + +Do not invert this order. + +If adapter/runtime wiring appears first, `V1` mixed state will silently regain +semantic authority through convenience behavior. + +## Existing Inputs To Preserve + +These are fixed inputs, not optional references: + +1. `v2_mini_core_design.md` +2. `v2-reuse-replacement-boundary.md` +3. `v2-protocol-claim-and-evidence.md` +4. `v2-phase-development-plan.md` +5. `sw-block/engine/replication/` + +## Overall Composition Model + +The full `V2` runtime should be composed from smaller automata rather than one +monolithic state machine. + +```mermaid +flowchart TD + assignmentState[AssignmentAutomaton] + recoveryState[RecoveryAutomaton] + boundaryState[BoundaryAutomaton] + modeState[ModeAutomaton] + publicationState[PublicationAutomaton] + coreEngine[CoreEngine] + projections[ProjectionContracts] + adapters[AdapterBoundary] + runtime[V1BackendMechanics] + + assignmentState --> coreEngine + recoveryState --> coreEngine + boundaryState --> coreEngine + modeState --> coreEngine + coreEngine --> publicationState + publicationState --> projections + coreEngine --> adapters + adapters --> runtime + runtime -->|"observations/events"| adapters + adapters --> coreEngine +``` + +## The Five Core-Owned Automata + +### 1. Assignment automaton + +Owns: + +1. volume intent +2. role intent +3. stable replica identity +4. epoch +5. desired replica set + +Primary constraints preserved: + +1. `CP13-2` +2. identity-vs-transport separation + +Current seeds: + +1. `sw-block/engine/replication/registry.go` +2. `sw-block/engine/replication/state.go` + +### 2. Recovery automaton + +Owns: + +1. per-replica recovery state +2. session ownership and fencing +3. catch-up vs rebuild selection + +Primary constraints preserved: + +1. `CP13-4` +2. `CP13-5` +3. `CP13-6` +4. `CP13-7` + +Current seeds: + +1. `sw-block/engine/replication/sender.go` +2. `sw-block/engine/replication/session.go` +3. `sw-block/engine/replication/orchestrator.go` +4. `sw-block/engine/replication/outcome.go` + +### 3. Boundary automaton + +Owns: + +1. committed truth +2. checkpoint truth +3. durable barrier truth +4. rebuild/catch-up target truth + +Primary constraints preserved: + +1. `T1` +2. `T9` +3. `CP13-3` + +Current seeds: + +1. `sw-block/engine/replication/state.go` +2. `sw-block/engine/replication/engine.go` + +### 4. Mode automaton + +Owns: + +1. `allocated_only` +2. `bootstrap_pending` +3. `replica_ready` +4. `publish_healthy` +5. `degraded` +6. `needs_rebuild` + +Primary constraints preserved: + +1. `CP13-9` +2. fail-closed external meaning + +Current seeds: + +1. `sw-block/engine/replication/state.go` +2. `sw-block/engine/replication/engine.go` + +### 5. Publication automaton + +Owns: + +1. readiness closure +2. publication closure +3. outward healthy vs non-healthy truth + +Primary constraints preserved: + +1. `CP13-8A` +2. `CP13-9` + +Current seeds: + +1. `sw-block/engine/replication/projection.go` +2. `sw-block/engine/replication/engine.go` + +## Phase 14+ Execution Order + +### Phase 14A: Core-owned automata + +Goal: + +1. make the five automata explicit in the core package + +Deliver: + +1. state definitions +2. transition tables/rules +3. event vocabulary + +Validation: + +1. structural acceptance tests in `sw-block/engine/replication` + +Non-goal: + +1. no live adapter hook + +### Phase 14B: Command semantics + +Goal: + +1. freeze command-emission rules from semantic state, not runtime convenience + +Deliver: + +1. command rules for role apply, receiver start, shipper configure, invalidation, + and publication + +Validation: + +1. tests that one event sequence produces one bounded command sequence + +Non-goal: + +1. no `weed/` execution yet + +### Phase 14C: Projection contracts + +Goal: + +1. define what external surfaces are allowed to claim and from which core state + +Deliver: + +1. projection structs and normalization rules for lookup/heartbeat/debug/tester + meanings + +Validation: + +1. mode/readiness/publication surface-consistency tests + +Non-goal: + +1. no live registry rewrite yet + +### Phase 15A: Minimal adapter hook + +Goal: + +1. connect one narrow adapter ingress to the new core + +Deliver: + +1. one event path from `weed/` into the core +2. one command path back out + +Validation: + +1. prove no semantic split between adapter and core on that narrow path + +### Phase 15B: Projection-store rebinding + +Goal: + +1. make `weed/` projection/state surfaces consume core-owned projection truth + +Deliver: + +1. bounded rebinding of registry / lookup / tester-facing surfaces + +Validation: + +1. prove assignment delivered != ready != publish healthy on the real path + +### Phase 16: V2-native runtime closure + +Goal: + +1. make the integrated runtime behave as a `V2`-owned system rather than + constrained-`V1` semantics plus fixes + +Deliver: + +1. one bounded runtime path where core-owned semantics drive adapters and + projections + +Validation: + +1. end-to-end failover/recovery/publication scenarios on the core-driven path + +## Algorithm Review Rule + +For any new transition rule, command rule, or projection rule, require a short +justification in code review or delivery notes: + +1. semantic constraint satisfied: + - which item from `v2-protocol-claim-and-evidence.md`, + `v2-protocol-truths.md`, or `CP13-*` +2. overclaim avoided: + - what false healthy / ready / durable / recoverable claim is being prevented +3. proof preserved: + - which accepted test or checkpoint remains valid because of this rule + +This is the minimum bar for `Phase 14+`. + +## Immediate Next Slice + +Do not broaden `Phase 13` further. + +Use the new `Phase 14` core skeleton in `sw-block/engine/replication` as the +base for one complete semantic chain: + +1. `mode` +2. `readiness` +3. `publication` + +This is the best next slice because it turns the newest accepted `CP13-8A` and +`CP13-9` constraints directly into core-owned state and transition logic before +adapter rebinding begins. diff --git a/sw-block/design/v2_mini_core_design.md b/sw-block/design/v2_mini_core_design.md index 10483b47c..8f43e1aa1 100644 --- a/sw-block/design/v2_mini_core_design.md +++ b/sw-block/design/v2_mini_core_design.md @@ -319,6 +319,73 @@ projection 是给外部世界看的,不是内部原始状态 dump。 - receiver ready - publish healthy +### 6.5 由五个小自动机构成的大自动机 + +`V2 core` 不应该被实现成一个“所有语义揉在一起”的大状态机。 +更稳的方式是把它拆成五个 core-owned automata,再由 `CoreEngine` 组合: + +1. `AssignmentAutomaton` + - volume intent + - role intent + - stable replica identity + - epoch + - desired replica set +2. `RecoveryAutomaton` + - session ownership + - catch-up vs rebuild selection + - per-replica recovery state +3. `BoundaryAutomaton` + - committed truth + - checkpoint truth + - durable barrier truth + - rebuild/catch-up target truth +4. `ModeAutomaton` + - `allocated_only` + - `bootstrap_pending` + - `replica_ready` + - `publish_healthy` + - `degraded` + - `needs_rebuild` +5. `PublicationAutomaton` + - readiness closure + - publication closure + - outward healthy vs non-healthy truth + +组合关系应该是: + +```mermaid +flowchart TD + assignmentState[AssignmentAutomaton] + recoveryState[RecoveryAutomaton] + boundaryState[BoundaryAutomaton] + modeState[ModeAutomaton] + publicationState[PublicationAutomaton] + coreEngine[CoreEngine] + projections[ProjectionContracts] + adapters[AdapterBoundary] + runtime[V1BackendMechanics] + + assignmentState --> coreEngine + recoveryState --> coreEngine + boundaryState --> coreEngine + modeState --> coreEngine + coreEngine --> publicationState + publicationState --> projections + coreEngine --> adapters + adapters --> runtime + runtime -->|"observations/events"| adapters + adapters --> coreEngine +``` + +这里最关键的工程规则是: + +1. 先定义 core-owned state / transitions +2. 再定义 command-emission rules +3. 再定义 projection contracts +4. 最后才接 adapter + +如果顺序反过来,`V1` mixed runtime state 会重新夺回语义 authority。 + --- ## 7. 直接映射到仓库路径 @@ -530,6 +597,20 @@ type FrontendBackend interface { - 为了抽接口,暂时模糊 readiness - 为了分层,暂时把 publish truth 放宽 +### 10.5 未来算法和状态转换的审查规则 +从 `Phase 14+` 开始,任何新的 transition rule / command rule / projection +rule,都应该在 code review 或 delivery note 里回答三件事: + +1. semantic constraint satisfied + - 它满足的是哪条 `claim / truth / accepted checkpoint` +2. overclaim avoided + - 它防止了哪种 false healthy / false ready / false durable / false recoverable +3. proof preserved + - 它保持了哪个已接受 proof 仍然成立 + +这不是文书要求,而是防止 `V2 core` 重新退化成“runtime convenience + +事后解释”的最低工程 bar。 + --- ## 11. V2 如何从已接受 claim 变得可靠 @@ -732,7 +813,419 @@ type FrontendBackend interface { --- -## 14. 推荐的实施顺序 +## 14. `V2 core` 的 Go package 目录建议 + +下面不是要求一次性重排仓库,而是给出一个与当前代码现实相容的目标布局。 + +建议把长期 `V2` 资产继续收敛在 `sw-block/engine/replication/` 之下,并按职责细分: + +```text +sw-block/engine/replication/ + core/ + types.go + state.go + event.go + command.go + projection.go + engine.go + session/ + session.go + ownership.go + boundary/ + lsn.go + retention.go + rebuild.go + adapter/ + contract.go + observe.go + projection/ + heartbeat.go + lookup.go + diagnostic.go +``` + +如果短期不想新建这么多子目录,也可以先保持现有目录平铺,但按相同职责收敛文件: + +1. `types.go`: 基础 types +2. `state.go`: state/ownership structs +3. `event.go`: incoming events +4. `command.go`: emitted commands +5. `projection.go`: outward projections +6. `engine.go`: `ApplyEvent() -> Decide()` +7. `session.go` / `sender.go` / `registry.go`: 继续作为现有核心实现承载 + +### 14.1 与现有代码的最小映射 + +当前已经存在的可直接承接 `V2 core` 的文件: + +- `sw-block/engine/replication/types.go` +- `sw-block/engine/replication/session.go` +- `sw-block/engine/replication/sender.go` +- `sw-block/engine/replication/registry.go` +- `sw-block/engine/replication/orchestrator.go` +- `sw-block/engine/replication/outcome.go` +- `sw-block/engine/replication/rebuild.go` +- `sw-block/engine/replication/observe.go` + +这些文件已经说明: + +- `V2 core` 不是概念草图 +- 它已经有 sender/session/ownership/orchestrator 的真实雏形 +- 后续更工程化的工作主要是把这些对象的职责再显式化,而不是重新发明 + +### 14.2 `weed/` 中的对应位置 + +`weed/` 中长期应当只保留: + +- backend primitive +- adapter boundary +- projection surface + +不应该让 `weed/` 成为长期语义 authority。 + +当前可暂时对应为: + +- adapter boundary: + - `weed/storage/blockvol/v2bridge/control.go` + - `weed/server/volume_server_block.go` + - `weed/server/block_heartbeat_loop.go` +- projection surface: + - `weed/server/master_block_registry.go` + - `weed/server/master_grpc_server_block.go` + - `weed/server/master_server_handlers_block.go` + - `weed/server/volume_server_block_debug.go` +- backend primitive: + - `weed/storage/blockvol/*` + +--- + +## 15. `struct / event / command / projection` 推荐文件落点 + +### 15.1 Struct + +建议放置: + +- `types.go` + - `Endpoint` + - `ReplicaID` + - `VolumeID` + - `SessionKind` + - `ReplicaState` +- `state.go` + - `VolumeIntent` + - `AssignmentView` + - `ReplicaSession` + - `RecoveryOwner` + - `BoundaryView` + - `ReadinessView` + +规则: + +- 基础标识和 enum 放 `types.go` +- 可变的 core state 放 `state.go` + +### 15.2 Event + +建议放 `event.go`: + +- `AssignmentDelivered` +- `EpochBumped` +- `RepeatedAssignmentDelivered` +- `RoleApplied` +- `ReceiverReadyObserved` +- `ShipperConfiguredObserved` +- `ShipperConnectedObserved` +- `BarrierAccepted` +- `BarrierRejected` +- `CommittedLSNAdvanced` +- `CheckpointLSNAdvanced` +- `RebuildCommitted` +- `HeartbeatCollected` + +规则: + +- event 只描述 observation 或 intent +- event 不直接携带“成功解释” + +### 15.3 Command + +建议放 `command.go`: + +- `ApplyRoleCommand` +- `StartReceiverCommand` +- `ConfigureShipperCommand` +- `StartCatchUpCommand` +- `StartRebuildCommand` +- `InvalidateSessionCommand` +- `PublishProjectionCommand` + +规则: + +- command 只表达“core 决定要做什么” +- backend 如何完成,不在 command 内定义 + +### 15.4 Projection + +建议放 `projection.go`,再按需要拆出: + +- `projection_lookup.go` +- `projection_heartbeat.go` +- `projection_diagnostic.go` + +至少包括: + +- `LookupProjection` +- `HeartbeatProjection` +- `DiagnosticProjection` +- `TesterProjection` + +规则: + +- projection 是外部可见 truth +- 不是内部 state dump + +### 15.5 当前仓库里的最小过渡落点 + +在不大改目录的前提下,可以先这样放: + +- `sw-block/engine/replication/types.go` + - 基础 type / enum +- `sw-block/engine/replication/session.go` + - session ownership +- `sw-block/engine/replication/registry.go` + - assignment reconcile +- `sw-block/engine/replication/orchestrator.go` + - engine entrypoint / event handling 骨架 +- 新增: + - `sw-block/engine/replication/event.go` + - `sw-block/engine/replication/command.go` + - `sw-block/engine/replication/projection.go` + +这样不会一开始就做大规模 package 重排,但可以先把抽象边界立起来。 + +--- + +## 16. 最小 `ApplyEvent() -> Decide() -> EmitCommands()` 伪代码骨架 + +下面给一个最小核心骨架,说明 `V2 core` 如何工作。 + +```go +type Core struct { + volumes map[string]*VolumeState +} + +type VolumeState struct { + Intent VolumeIntent + Assignment AssignmentView + Session map[string]*ReplicaSession + Ownership map[string]*RecoveryOwner + Boundary BoundaryView + Readiness ReadinessView +} + +func (c *Core) ApplyEvent(ev Event) []Command { + st := c.mustVolumeState(ev.VolumeID()) + + switch e := ev.(type) { + case AssignmentDelivered: + st.Intent = mergeIntent(st.Intent, e.Intent) + st.Assignment = buildAssignmentView(e) + return c.decideFromAssignment(st) + + case RoleApplied: + st.Readiness.RoleApplied = true + return c.decidePublication(st) + + case ReceiverReadyObserved: + st.Readiness.ReceiverReady = true + st.Readiness.ReplicaEligible = true + return c.decidePublication(st) + + case ShipperConfiguredObserved: + st.Readiness.ShipperConfigured = true + return nil + + case ShipperConnectedObserved: + st.Readiness.ShipperConnected = true + return c.decidePublication(st) + + case BarrierAccepted: + st.Boundary.CommittedLSN = e.FlushedLSN + return c.decideDurability(st) + + case CheckpointLSNAdvanced: + st.Boundary.CheckpointLSN = e.CheckpointLSN + return nil + + case RebuildCommitted: + st.Boundary.AchievedLSN = e.AchievedLSN + st.Readiness.ReplicaEligible = true + return c.decidePublication(st) + + default: + return nil + } +} + +func (c *Core) decideFromAssignment(st *VolumeState) []Command { + var cmds []Command + + cmds = append(cmds, ApplyRoleCommand{ + VolumeID: st.Assignment.VolumeID, + Epoch: st.Assignment.Epoch, + Role: st.Assignment.Role, + }) + + if st.Assignment.Role == RoleReplica { + cmds = append(cmds, StartReceiverCommand{ + VolumeID: st.Assignment.VolumeID, + DataAddr: st.Assignment.ReplicaDataAddr(), + CtrlAddr: st.Assignment.ReplicaCtrlAddr(), + }) + } + + if st.Assignment.Role == RolePrimary && len(st.Assignment.ReplicaEndpoints) > 0 { + cmds = append(cmds, ConfigureShipperCommand{ + VolumeID: st.Assignment.VolumeID, + Replicas: st.Assignment.ReplicaEndpoints.All(), + }) + } + + return cmds +} + +func (c *Core) decidePublication(st *VolumeState) []Command { + st.Readiness.PublishHealthy = + st.Readiness.RoleApplied && + ((st.Assignment.Role == RolePrimary) || + (st.Readiness.ReceiverReady && st.Readiness.ReplicaEligible)) + + return []Command{ + PublishProjectionCommand{ + VolumeID: st.Assignment.VolumeID, + Readiness: st.Readiness, + }, + } +} +``` + +这个骨架的重点不是代码细节,而是三件事: + +1. `event` 先进入 core +2. core 决定 state transition +3. command 由 core 发出,adapter 去执行 + +这样就不会再出现: + +- runtime 自己偷偷解释语义 +- workload 自己顺便把系统推到 healthy +- publication 在 readiness 闭环前提前放行 + +--- + +## 17. `V2 prototype` 与 `mini core design` 的关系 + +这份 `mini core design` 不是脱离 prototype 重新发明的。 +它和 `V2 prototype` 是继承关系,不是替代关系。 + +### 17.1 prototype 提供了什么 + +prototype / FSM / simulator 已经证明了: + +1. 哪些状态对象必须存在 +2. 哪些事件必须显式 +3. 哪些 failure class 必须 fail-closed +4. 哪些 boundary 不能靠实现便利重新解释 + +也就是说,prototype 提供的是: + +- 语义骨架 +- 状态机对象 +- failure-class closure 直觉 +- ownership / session / boundary 的必要性 + +### 17.2 mini core design 补的是什么 + +`mini core design` 解决的不是“语义是否存在”,而是: + +1. 这些语义对象在当前仓库里应该落在哪些 package / file +2. 谁拥有这些对象 +3. 哪些通过 adapter 进入 `weed/` +4. 哪些 `V1` 行为可以复用 +5. 哪些当前 `weed/` 改动只是现实验证资产 + +所以: + +- prototype 负责回答 “应该是什么” +- mini core design 负责回答 “在当前代码库里怎么做” + +### 17.3 为什么不能直接把 prototype 当 production 目录 + +因为 production 还必须处理: + +- heartbeat / master registry +- frontend projection +- diagnostics +- current backend reuse +- bounded envelope / claim / evidence integration + +所以不能简单把 prototype 原样搬进 `weed/` 或 production path。 + +更合理的关系是: + +1. prototype 提供语义模板 +2. `sw-block/engine/replication/` 承接 core semantics +3. adapter 把这些 semantics 翻译到当前 runtime +4. backend 继续执行 I/O / transport primitives + +### 17.4 一个总规则 + +> `mini core design` 的任务不是重新设计 prototype 已经证明必要的语义对象。 +> 它的任务是把这些对象放到当前代码库中可落地、可维护、可验证的位置,并明确它们与 `V1 backend` 的边界。 + +### 17.5 在 `V2 core` 建立前如何解释当前测试 + +在 `V2 core` 还没有以明确 package / file / event-command loop 形式落地之前, +当前大多数 integrated tests 都应该这样解释: + +1. 不是在验证一个已经完成的 `V2 runtime` +2. 而是在用 `V2` 的 truth / constraint / acceptance bar 去检查当前 `V1` runtime 的表现 + +更准确地说: + +- 现在已经存在的是: + - `V2` 的 truth + - `V2` 的 claim boundary + - `V2` 的 failure-class discipline + - `V2` 的 prototype / FSM / mini-core design +- 现在还没有完全存在的是: + - 明确拥有 runtime 语义 authority 的 `V2 core` + - 真实 live path 上的 `ApplyEvent() -> Decide() -> EmitCommands()` 闭环 + - 被正式降级成 adapter / backend reality 的 `weed/` 运行结构 + +因此,像 `CP13-8` 这样的结果,当前最准确的解释不是: + +- `V2 runtime` 已验证 + +而是: + +- 当前 `V1` 运行现实在 `V2` 约束下,通过了一个 bounded envelope 的真实 workload 检查 + +这类检查点的价值依然很高,因为它证明: + +1. 当前复用路径不是完全错误的 +2. `V2` 约束能够识别和纠正真实 bug +3. 当前运行现实值得继续被抽象、剥离,并最终收敛成真正的 `V2 core` + +所以在后续 phase 中,应当默认区分三种东西: + +1. `V2` 语义证明 +2. `V1-under-V2-constraints` 的运行验证 +3. 后续真正的 `V2 core` 提取与 runtime closure + +--- + +## 18. 推荐的实施顺序 ### 近期 1. 继续收紧 assignment -> readiness -> publication closure @@ -751,7 +1244,7 @@ type FrontendBackend interface { --- -## 15. 最短结论 +## 19. 最短结论 你要的“更工程化”版本可以归纳为一句话: diff --git a/sw-block/engine/replication/command.go b/sw-block/engine/replication/command.go new file mode 100644 index 000000000..fa1340a72 --- /dev/null +++ b/sw-block/engine/replication/command.go @@ -0,0 +1,42 @@ +package replication + +// Command is one side-effect-free decision emitted by the Phase 14 core skeleton. +// Adapters execute commands later; the core only decides them. +type Command interface { + commandName() string +} + +type ApplyRoleCommand struct { + VolumeID string + Epoch uint64 + Role VolumeRole +} + +func (ApplyRoleCommand) commandName() string { return "apply_role" } + +type StartReceiverCommand struct { + VolumeID string +} + +func (StartReceiverCommand) commandName() string { return "start_receiver" } + +type ConfigureShipperCommand struct { + VolumeID string + Replicas []ReplicaAssignment +} + +func (ConfigureShipperCommand) commandName() string { return "configure_shipper" } + +type InvalidateSessionCommand struct { + VolumeID string + Reason string +} + +func (InvalidateSessionCommand) commandName() string { return "invalidate_session" } + +type PublishProjectionCommand struct { + VolumeID string + Projection PublicationProjection +} + +func (PublishProjectionCommand) commandName() string { return "publish_projection" } diff --git a/sw-block/engine/replication/doc.go b/sw-block/engine/replication/doc.go index d20c974b1..744a01093 100644 --- a/sw-block/engine/replication/doc.go +++ b/sw-block/engine/replication/doc.go @@ -15,13 +15,23 @@ // File layout: // // Slice 1 core (ownership/fencing): -// types.go — Endpoint, ReplicaState, SessionKind, SessionPhase -// sender.go — Sender: per-replica owner with execution APIs -// session.go — Session: recovery lifecycle with FSM phases -// registry.go — Registry: sender group with reconcile + assignment intent +// +// types.go — Endpoint, ReplicaState, SessionKind, SessionPhase +// sender.go — Sender: per-replica owner with execution APIs +// session.go — Session: recovery lifecycle with FSM phases +// registry.go — Registry: sender group with reconcile + assignment intent +// +// Phase 14 explicit core shell: +// +// state.go — VolumeState, readiness, boundary, normalized mode +// event.go — bounded event vocabulary +// command.go — side-effect-free command decisions +// projection.go — bounded outward publication projection +// engine.go — ApplyEvent() -> state update -> emit commands/projection // // Carried forward from prototype (accepted in Phase 4.5): -// budget.go — CatchUpBudget: bounded catch-up enforcement -// rebuild.go — RebuildState: rebuild execution FSM -// outcome.go — HandshakeResult, RecoveryOutcome classification +// +// budget.go — CatchUpBudget: bounded catch-up enforcement +// rebuild.go — RebuildState: rebuild execution FSM +// outcome.go — HandshakeResult, RecoveryOutcome classification package replication diff --git a/sw-block/engine/replication/engine.go b/sw-block/engine/replication/engine.go new file mode 100644 index 000000000..8602e40f6 --- /dev/null +++ b/sw-block/engine/replication/engine.go @@ -0,0 +1,342 @@ +package replication + +// CoreEngine is the first explicit Phase 14 V2 core shell. +// It is deterministic and side-effect free: one event in, updated state and +// commands/projection out. +type CoreEngine struct { + volumes map[string]*VolumeState +} + +// ApplyResult is the full result of handling one event. +type ApplyResult struct { + Commands []Command + Projection PublicationProjection + State VolumeState +} + +// NewCoreEngine creates an empty core. +func NewCoreEngine() *CoreEngine { + return &CoreEngine{volumes: map[string]*VolumeState{}} +} + +// ApplyEvent mutates the bounded core state and emits commands/projection. +func (e *CoreEngine) ApplyEvent(ev Event) ApplyResult { + st := e.mustState(ev.VolumeID()) + var cmds []Command + + switch v := ev.(type) { + case AssignmentDelivered: + cmds = append(cmds, e.applyAssignment(st, v)...) + + case RoleApplied: + st.Readiness.RoleApplied = true + + case ReceiverReadyObserved: + st.Readiness.ReceiverReady = true + + case ShipperConfiguredObserved: + st.Readiness.ShipperConfigured = true + + case ShipperConnectedObserved: + st.Readiness.ShipperConnected = true + + case DiagnosticShippedAdvanced: + if v.ShippedLSN > st.Boundary.DiagnosticShippedLSN { + st.Boundary.DiagnosticShippedLSN = v.ShippedLSN + } + + case BarrierAccepted: + if v.FlushedLSN > st.Boundary.DurableLSN { + st.Boundary.DurableLSN = v.FlushedLSN + } + st.Boundary.LastBarrierOK = true + st.Boundary.LastBarrierReason = "" + st.degraded = false + st.degradeReason = "" + + case BarrierRejected: + st.Boundary.LastBarrierOK = false + st.Boundary.LastBarrierReason = v.Reason + st.degraded = true + st.degradeReason = v.Reason + if st.shouldInvalidate(v.Reason) { + cmds = append(cmds, InvalidateSessionCommand{ + VolumeID: st.VolumeID, + Reason: v.Reason, + }) + st.commands.InvalidationIssued = true + st.commands.InvalidationReason = v.Reason + } + + case CheckpointAdvanced: + if v.CheckpointLSN > st.Boundary.CheckpointLSN { + st.Boundary.CheckpointLSN = v.CheckpointLSN + } + + case NeedsRebuildObserved: + st.needsRebuild = true + st.rebuildReason = v.Reason + st.degraded = false + st.degradeReason = "" + if st.shouldInvalidate(v.Reason) { + cmds = append(cmds, InvalidateSessionCommand{ + VolumeID: st.VolumeID, + Reason: v.Reason, + }) + st.commands.InvalidationIssued = true + st.commands.InvalidationReason = v.Reason + } + + case RebuildCommitted: + st.needsRebuild = false + st.rebuildReason = "" + st.degraded = false + st.degradeReason = "" + st.resetInvalidation() + if v.FlushedLSN > st.Boundary.DurableLSN { + st.Boundary.DurableLSN = v.FlushedLSN + } + if v.CheckpointLSN > st.Boundary.CheckpointLSN { + st.Boundary.CheckpointLSN = v.CheckpointLSN + } + } + + e.recompute(st) + proj := e.projectionFor(st) + cmds = append(cmds, PublishProjectionCommand{ + VolumeID: st.VolumeID, + Projection: proj, + }) + + return ApplyResult{ + Commands: cmds, + Projection: proj, + State: st.Snapshot(), + } +} + +// State returns a detached copy of a volume state. +func (e *CoreEngine) State(volumeID string) (VolumeState, bool) { + st, ok := e.volumes[volumeID] + if !ok { + return VolumeState{}, false + } + return st.Snapshot(), true +} + +// Projection returns the current outward projection for a volume. +func (e *CoreEngine) Projection(volumeID string) (PublicationProjection, bool) { + st, ok := e.volumes[volumeID] + if !ok { + return PublicationProjection{}, false + } + return e.projectionFor(st), true +} + +func (e *CoreEngine) mustState(volumeID string) *VolumeState { + if st, ok := e.volumes[volumeID]; ok { + return st + } + st := newVolumeState(volumeID) + e.volumes[volumeID] = st + return st +} + +func (e *CoreEngine) recompute(st *VolumeState) { + st.Readiness.ReplicaReady = st.Role == RoleReplica && + st.Readiness.RoleApplied && + st.Readiness.ReceiverReady + st.Readiness.PublishHealthy = false + st.Mode.Authority = RuntimeAuthorityConstrainedV1 + st.Mode.Reason = "" + st.Publication = PublicationView{} + + switch { + case st.needsRebuild: + st.Publication.Reason = defaultReason(st.rebuildReason, "needs_rebuild") + st.Mode.Name = ModeNeedsRebuild + st.Mode.Reason = st.Publication.Reason + case st.degraded: + st.Publication.Reason = defaultReason(st.degradeReason, "degraded") + st.Mode.Name = ModeDegraded + st.Mode.Reason = st.Publication.Reason + case !st.hasReplicas(): + st.Publication.Reason = "allocated_only" + st.Mode.Name = ModeAllocatedOnly + case st.Role == RoleReplica && st.Readiness.ReplicaReady: + st.Publication.Reason = "replica_not_primary" + st.Mode.Name = ModeReplicaReady + case st.Role == RolePrimary && st.primaryEligibleForPublish(): + st.Publication.Healthy = true + st.Mode.Name = ModePublishHealthy + st.Readiness.PublishHealthy = true + case st.Readiness.Assigned || st.Readiness.RoleApplied: + st.Publication.Reason = st.bootstrapReason() + st.Mode.Name = ModeBootstrapPending + st.Mode.Reason = st.Publication.Reason + default: + st.Publication.Reason = "allocated_only" + st.Mode.Name = ModeAllocatedOnly + } +} + +func (e *CoreEngine) applyAssignment(st *VolumeState, ev AssignmentDelivered) []Command { + roleChanged := st.Role != ev.Role + epochChanged := st.Epoch != ev.Epoch + replicasChanged := !sameReplicaAssignments(st.DesiredReplicas, ev.Replicas) + + st.Epoch = ev.Epoch + st.Role = ev.Role + st.DesiredReplicas = append([]ReplicaAssignment(nil), ev.Replicas...) + st.Readiness.Assigned = true + st.Mode.Authority = RuntimeAuthorityConstrainedV1 + + if epochChanged || roleChanged { + st.Readiness.RoleApplied = false + } + + switch ev.Role { + case RoleReplica: + if epochChanged || roleChanged { + st.Readiness.ReceiverReady = false + } + st.Readiness.ShipperConfigured = false + st.Readiness.ShipperConnected = false + case RolePrimary: + st.Readiness.ReceiverReady = false + if epochChanged || roleChanged || replicasChanged { + st.Readiness.ShipperConfigured = false + st.Readiness.ShipperConnected = false + } + default: + st.Readiness.ReceiverReady = false + st.Readiness.ShipperConfigured = false + st.Readiness.ShipperConnected = false + } + + if epochChanged || roleChanged || replicasChanged { + st.degraded = false + st.degradeReason = "" + st.resetInvalidation() + } + + var cmds []Command + if st.shouldApplyRole() { + cmds = append(cmds, ApplyRoleCommand{ + VolumeID: st.VolumeID, + Epoch: st.Epoch, + Role: st.Role, + }) + st.commands.RoleEpoch = st.Epoch + st.commands.Role = st.Role + } + if st.shouldStartReceiver() { + cmds = append(cmds, StartReceiverCommand{VolumeID: st.VolumeID}) + st.commands.ReceiverStartEpoch = st.Epoch + } + if st.shouldConfigureShipper() { + cmds = append(cmds, ConfigureShipperCommand{ + VolumeID: st.VolumeID, + Replicas: append([]ReplicaAssignment(nil), st.DesiredReplicas...), + }) + st.commands.ShipperConfigEpoch = st.Epoch + st.commands.ShipperConfigReplicas = append([]ReplicaAssignment(nil), st.DesiredReplicas...) + } + return cmds +} + +func (e *CoreEngine) projectionFor(st *VolumeState) PublicationProjection { + replicaIDs := make([]string, 0, len(st.DesiredReplicas)) + for _, replica := range st.DesiredReplicas { + replicaIDs = append(replicaIDs, replica.ReplicaID) + } + return PublicationProjection{ + VolumeID: st.VolumeID, + Epoch: st.Epoch, + Role: st.Role, + Mode: st.Mode, + Publication: st.Publication, + Readiness: st.Readiness, + Boundary: st.Boundary, + ReplicaIDs: replicaIDs, + PublishHealthy: st.Publication.Healthy, + } +} + +func (st *VolumeState) primaryEligibleForPublish() bool { + return st.Role == RolePrimary && + st.Readiness.RoleApplied && + st.Readiness.ShipperConfigured && + st.Readiness.ShipperConnected && + st.Boundary.DurableLSN > 0 +} + +func (st *VolumeState) shouldApplyRole() bool { + return st.commands.RoleEpoch != st.Epoch || st.commands.Role != st.Role +} + +func (st *VolumeState) shouldStartReceiver() bool { + return st.Role == RoleReplica && + !st.Readiness.ReceiverReady && + st.commands.ReceiverStartEpoch != st.Epoch +} + +func (st *VolumeState) shouldConfigureShipper() bool { + return st.Role == RolePrimary && + st.hasReplicas() && + !st.Readiness.ShipperConfigured && + (st.commands.ShipperConfigEpoch != st.Epoch || + !sameReplicaAssignments(st.commands.ShipperConfigReplicas, st.DesiredReplicas)) +} + +func (st *VolumeState) shouldInvalidate(reason string) bool { + return !st.commands.InvalidationIssued || st.commands.InvalidationReason != reason +} + +func (st *VolumeState) resetInvalidation() { + st.commands.InvalidationIssued = false + st.commands.InvalidationReason = "" +} + +func (st *VolumeState) hasReplicas() bool { + return len(st.DesiredReplicas) > 0 +} + +func (st *VolumeState) bootstrapReason() string { + switch { + case !st.Readiness.RoleApplied: + return "awaiting_role_apply" + case st.Role == RoleReplica && !st.Readiness.ReceiverReady: + return "awaiting_receiver_ready" + case st.Role == RolePrimary && !st.Readiness.ShipperConfigured: + return "awaiting_shipper_configured" + case st.Role == RolePrimary && !st.Readiness.ShipperConnected: + return "awaiting_shipper_connected" + case st.Role == RolePrimary && st.Boundary.DurableLSN == 0: + return "awaiting_barrier_durability" + default: + return "bootstrap_pending" + } +} + +func defaultReason(reason, fallback string) string { + if reason != "" { + return reason + } + return fallback +} + +func sameReplicaAssignments(left, right []ReplicaAssignment) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i].ReplicaID != right[i].ReplicaID { + return false + } + if left[i].Endpoint != right[i].Endpoint { + return false + } + } + return true +} diff --git a/sw-block/engine/replication/event.go b/sw-block/engine/replication/event.go new file mode 100644 index 000000000..6ff1b2ad0 --- /dev/null +++ b/sw-block/engine/replication/event.go @@ -0,0 +1,94 @@ +package replication + +// Event is one bounded input into the Phase 14 core skeleton. +// Events describe observation or intent; they do not perform side effects. +type Event interface { + VolumeID() string +} + +// AssignmentDelivered carries the desired local role and replica set. +type AssignmentDelivered struct { + ID string + Epoch uint64 + Role VolumeRole + Replicas []ReplicaAssignment +} + +func (e AssignmentDelivered) VolumeID() string { return e.ID } + +// RoleApplied confirms the local runtime applied the desired role. +type RoleApplied struct { + ID string +} + +func (e RoleApplied) VolumeID() string { return e.ID } + +// ReceiverReadyObserved confirms replica receiver readiness. +type ReceiverReadyObserved struct { + ID string +} + +func (e ReceiverReadyObserved) VolumeID() string { return e.ID } + +// ShipperConfiguredObserved confirms shipper wiring exists. +type ShipperConfiguredObserved struct { + ID string +} + +func (e ShipperConfiguredObserved) VolumeID() string { return e.ID } + +// ShipperConnectedObserved confirms the shipper is connected. +type ShipperConnectedObserved struct { + ID string +} + +func (e ShipperConnectedObserved) VolumeID() string { return e.ID } + +// DiagnosticShippedAdvanced updates sender-side diagnostic progress only. +type DiagnosticShippedAdvanced struct { + ID string + ShippedLSN uint64 +} + +func (e DiagnosticShippedAdvanced) VolumeID() string { return e.ID } + +// BarrierAccepted advances authoritative durable progress. +type BarrierAccepted struct { + ID string + FlushedLSN uint64 +} + +func (e BarrierAccepted) VolumeID() string { return e.ID } + +// BarrierRejected marks the bounded path degraded until a later success clears it. +type BarrierRejected struct { + ID string + Reason string +} + +func (e BarrierRejected) VolumeID() string { return e.ID } + +// CheckpointAdvanced updates the durable base-image boundary. +type CheckpointAdvanced struct { + ID string + CheckpointLSN uint64 +} + +func (e CheckpointAdvanced) VolumeID() string { return e.ID } + +// NeedsRebuildObserved is a fail-closed rebuild escalation. +type NeedsRebuildObserved struct { + ID string + Reason string +} + +func (e NeedsRebuildObserved) VolumeID() string { return e.ID } + +// RebuildCommitted clears the rebuild condition with bounded durable truth. +type RebuildCommitted struct { + ID string + FlushedLSN uint64 + CheckpointLSN uint64 +} + +func (e RebuildCommitted) VolumeID() string { return e.ID } diff --git a/sw-block/engine/replication/phase14_command_test.go b/sw-block/engine/replication/phase14_command_test.go new file mode 100644 index 000000000..b5f5b66e8 --- /dev/null +++ b/sw-block/engine/replication/phase14_command_test.go @@ -0,0 +1,124 @@ +package replication + +import ( + "reflect" + "testing" +) + +func TestPhase14_CommandSequence_PrimaryAssignmentIsBounded(t *testing.T) { + core := NewCoreEngine() + ev := AssignmentDelivered{ + ID: "vol-cmd-primary", + Epoch: 1, + Role: RolePrimary, + Replicas: []ReplicaAssignment{ + {ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.10:9333", CtrlAddr: "10.0.0.10:9334", Version: 1}}, + }, + } + + result := core.ApplyEvent(ev) + assertCommandNames(t, result.Commands, []string{ + "apply_role", + "configure_shipper", + "publish_projection", + }) + + result = core.ApplyEvent(ev) + assertCommandNames(t, result.Commands, []string{ + "publish_projection", + }) +} + +func TestPhase14_CommandSequence_ReplicaAssignmentIsBounded(t *testing.T) { + core := NewCoreEngine() + ev := AssignmentDelivered{ + ID: "vol-cmd-replica", + Epoch: 3, + Role: RoleReplica, + Replicas: []ReplicaAssignment{ + {ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.11:9333", CtrlAddr: "10.0.0.11:9334", Version: 1}}, + }, + } + + result := core.ApplyEvent(ev) + assertCommandNames(t, result.Commands, []string{ + "apply_role", + "start_receiver", + "publish_projection", + }) + + result = core.ApplyEvent(ev) + assertCommandNames(t, result.Commands, []string{ + "publish_projection", + }) +} + +func TestPhase14_CommandSequence_AssignmentChangeReissuesNeededCommand(t *testing.T) { + core := NewCoreEngine() + + initial := AssignmentDelivered{ + ID: "vol-cmd-change", + Epoch: 5, + Role: RolePrimary, + Replicas: []ReplicaAssignment{ + {ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.12:9333", CtrlAddr: "10.0.0.12:9334", Version: 1}}, + }, + } + core.ApplyEvent(initial) + core.ApplyEvent(RoleApplied{ID: "vol-cmd-change"}) + core.ApplyEvent(ShipperConfiguredObserved{ID: "vol-cmd-change"}) + core.ApplyEvent(ShipperConnectedObserved{ID: "vol-cmd-change"}) + + changed := AssignmentDelivered{ + ID: "vol-cmd-change", + Epoch: 5, + Role: RolePrimary, + Replicas: []ReplicaAssignment{ + {ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.13:9333", CtrlAddr: "10.0.0.13:9334", Version: 2}}, + }, + } + result := core.ApplyEvent(changed) + assertCommandNames(t, result.Commands, []string{ + "configure_shipper", + "publish_projection", + }) +} + +func TestPhase14_CommandSequence_InvalidateOnlyOnNewFailureTransition(t *testing.T) { + core := NewCoreEngine() + + core.ApplyEvent(AssignmentDelivered{ + ID: "vol-cmd-failure", + Epoch: 1, + Role: RolePrimary, + Replicas: []ReplicaAssignment{ + {ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.14:9333", CtrlAddr: "10.0.0.14:9334", Version: 1}}, + }, + }) + core.ApplyEvent(RoleApplied{ID: "vol-cmd-failure"}) + core.ApplyEvent(ShipperConfiguredObserved{ID: "vol-cmd-failure"}) + core.ApplyEvent(ShipperConnectedObserved{ID: "vol-cmd-failure"}) + core.ApplyEvent(BarrierAccepted{ID: "vol-cmd-failure", FlushedLSN: 9}) + + result := core.ApplyEvent(BarrierRejected{ID: "vol-cmd-failure", Reason: "timeout"}) + assertCommandNames(t, result.Commands, []string{ + "invalidate_session", + "publish_projection", + }) + + result = core.ApplyEvent(BarrierRejected{ID: "vol-cmd-failure", Reason: "timeout"}) + assertCommandNames(t, result.Commands, []string{ + "publish_projection", + }) +} + +func assertCommandNames(t *testing.T, cmds []Command, want []string) { + t.Helper() + got := make([]string, 0, len(cmds)) + for _, cmd := range cmds { + got = append(got, cmd.commandName()) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("commands=%v, want %v", got, want) + } +} diff --git a/sw-block/engine/replication/phase14_core_test.go b/sw-block/engine/replication/phase14_core_test.go new file mode 100644 index 000000000..25ed7ff18 --- /dev/null +++ b/sw-block/engine/replication/phase14_core_test.go @@ -0,0 +1,217 @@ +package replication + +import "testing" + +func TestPhase14_Identity_StableReplicaIDPreservesOwnership(t *testing.T) { + r := NewRegistry() + r.ApplyAssignment(AssignmentIntent{ + Replicas: []ReplicaAssignment{ + { + ReplicaID: "replica-1", + Endpoint: Endpoint{ + DataAddr: "10.0.0.1:9333", + CtrlAddr: "10.0.0.1:9334", + Version: 1, + }, + }, + }, + Epoch: 1, + RecoveryTargets: map[string]SessionKind{"replica-1": SessionCatchUp}, + }) + + sender := r.Sender("replica-1") + oldSessionID := sender.SessionID() + if err := sender.BeginConnect(oldSessionID); err != nil { + t.Fatalf("begin connect: %v", err) + } + + r.Reconcile([]ReplicaAssignment{ + { + ReplicaID: "replica-1", + Endpoint: Endpoint{ + DataAddr: "10.0.0.2:9333", + CtrlAddr: "10.0.0.2:9334", + Version: 2, + }, + }, + }, 1) + + if got := r.Sender("replica-1"); got != sender { + t.Fatal("stable ReplicaID should preserve sender ownership") + } + if sender.HasActiveSession() { + t.Fatal("endpoint change should invalidate active ownership session") + } + if sender.Endpoint().DataAddr != "10.0.0.2:9333" { + t.Fatalf("endpoint not updated: %s", sender.Endpoint().DataAddr) + } +} + +func TestPhase14_CorePublishHealthyRequiresBarrierDurability(t *testing.T) { + core := NewCoreEngine() + + result := core.ApplyEvent(AssignmentDelivered{ + ID: "vol-a", + Epoch: 7, + Role: RolePrimary, + Replicas: []ReplicaAssignment{ + {ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.2:9333", CtrlAddr: "10.0.0.2:9334", Version: 1}}, + }, + }) + if result.Projection.Mode.Name != ModeBootstrapPending { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + if result.Projection.Publication.Reason != "awaiting_role_apply" { + t.Fatalf("publication_reason=%q", result.Projection.Publication.Reason) + } + if result.Projection.PublishHealthy { + t.Fatal("fresh assignment must not publish healthy") + } + + result = core.ApplyEvent(RoleApplied{ID: "vol-a"}) + if result.Projection.Publication.Reason != "awaiting_shipper_configured" { + t.Fatalf("publication_reason=%q", result.Projection.Publication.Reason) + } + result = core.ApplyEvent(ShipperConfiguredObserved{ID: "vol-a"}) + if result.Projection.Publication.Reason != "awaiting_shipper_connected" { + t.Fatalf("publication_reason=%q", result.Projection.Publication.Reason) + } + result = core.ApplyEvent(ShipperConnectedObserved{ID: "vol-a"}) + if result.Projection.PublishHealthy { + t.Fatal("connected shipper without barrier durability must stay non-healthy") + } + if result.Projection.Mode.Name != ModeBootstrapPending { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + if result.Projection.Publication.Reason != "awaiting_barrier_durability" { + t.Fatalf("publication_reason=%q", result.Projection.Publication.Reason) + } + + result = core.ApplyEvent(BarrierAccepted{ID: "vol-a", FlushedLSN: 12}) + if !result.Projection.PublishHealthy { + t.Fatal("barrier durability should enable publish healthy on eligible primary path") + } + if result.Projection.Mode.Name != ModePublishHealthy { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + if result.Projection.Boundary.DurableLSN != 12 { + t.Fatalf("durable_lsn=%d", result.Projection.Boundary.DurableLSN) + } + if result.Projection.Publication.Reason != "" { + t.Fatalf("publication_reason=%q", result.Projection.Publication.Reason) + } +} + +func TestPhase14_CoreDiagnosticShippedDoesNotCreateDurableTruth(t *testing.T) { + core := NewCoreEngine() + + core.ApplyEvent(AssignmentDelivered{ + ID: "vol-b", + Epoch: 3, + Role: RolePrimary, + Replicas: []ReplicaAssignment{{ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.3:9333", Version: 1}}}, + }) + core.ApplyEvent(RoleApplied{ID: "vol-b"}) + core.ApplyEvent(ShipperConfiguredObserved{ID: "vol-b"}) + core.ApplyEvent(ShipperConnectedObserved{ID: "vol-b"}) + + result := core.ApplyEvent(DiagnosticShippedAdvanced{ID: "vol-b", ShippedLSN: 99}) + if result.Projection.Boundary.DurableLSN != 0 { + t.Fatalf("durable_lsn=%d, want 0", result.Projection.Boundary.DurableLSN) + } + if result.Projection.Boundary.DiagnosticShippedLSN != 99 { + t.Fatalf("diagnostic_shipped=%d", result.Projection.Boundary.DiagnosticShippedLSN) + } + if result.Projection.PublishHealthy { + t.Fatal("diagnostic shipped progress must not establish durable healthy publication") + } + if result.Projection.Mode.Name != ModeBootstrapPending { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } +} + +func TestPhase14_CoreFailClosedModesStayDistinct(t *testing.T) { + core := NewCoreEngine() + + core.ApplyEvent(AssignmentDelivered{ + ID: "vol-c", + Epoch: 1, + Role: RolePrimary, + Replicas: []ReplicaAssignment{{ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.4:9333", Version: 1}}}, + }) + core.ApplyEvent(RoleApplied{ID: "vol-c"}) + core.ApplyEvent(ShipperConfiguredObserved{ID: "vol-c"}) + core.ApplyEvent(ShipperConnectedObserved{ID: "vol-c"}) + core.ApplyEvent(BarrierAccepted{ID: "vol-c", FlushedLSN: 8}) + + result := core.ApplyEvent(BarrierRejected{ID: "vol-c", Reason: "shipper_timeout"}) + if result.Projection.Mode.Name != ModeDegraded { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + if result.Projection.PublishHealthy { + t.Fatal("degraded mode must fail closed") + } + + result = core.ApplyEvent(NeedsRebuildObserved{ID: "vol-c", Reason: "gap_too_large"}) + if result.Projection.Mode.Name != ModeNeedsRebuild { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + if result.Projection.PublishHealthy { + t.Fatal("needs_rebuild must fail closed") + } + if result.Projection.Mode.Reason != "gap_too_large" { + t.Fatalf("reason=%q", result.Projection.Mode.Reason) + } +} + +func TestPhase14_CoreProjectionMarksConstrainedRuntimeAuthority(t *testing.T) { + core := NewCoreEngine() + + result := core.ApplyEvent(AssignmentDelivered{ + ID: "vol-d", + Epoch: 9, + Role: RoleReplica, + Replicas: []ReplicaAssignment{ + {ReplicaID: "replica-1", Endpoint: Endpoint{DataAddr: "10.0.0.5:9333", Version: 1}}, + }, + }) + + if result.Projection.Mode.Authority != RuntimeAuthorityConstrainedV1 { + t.Fatalf("authority=%s", result.Projection.Mode.Authority) + } + if result.Projection.Mode.Name != ModeBootstrapPending { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + + result = core.ApplyEvent(RoleApplied{ID: "vol-d"}) + result = core.ApplyEvent(ReceiverReadyObserved{ID: "vol-d"}) + if result.Projection.Mode.Name != ModeReplicaReady { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + if result.Projection.PublishHealthy { + t.Fatal("replica-ready is not the same as publish-healthy") + } + if result.Projection.Publication.Reason != "replica_not_primary" { + t.Fatalf("publication_reason=%q", result.Projection.Publication.Reason) + } +} + +func TestPhase14_CoreAllocatedOnlyWithoutReplicas(t *testing.T) { + core := NewCoreEngine() + + result := core.ApplyEvent(AssignmentDelivered{ + ID: "vol-rf1", + Epoch: 1, + Role: RolePrimary, + }) + + if result.Projection.Mode.Name != ModeAllocatedOnly { + t.Fatalf("mode=%s", result.Projection.Mode.Name) + } + if result.Projection.PublishHealthy { + t.Fatal("allocated-only volume must not publish healthy") + } + if result.Projection.Publication.Reason != "allocated_only" { + t.Fatalf("publication_reason=%q", result.Projection.Publication.Reason) + } +} diff --git a/sw-block/engine/replication/projection.go b/sw-block/engine/replication/projection.go new file mode 100644 index 000000000..1c8ae2b98 --- /dev/null +++ b/sw-block/engine/replication/projection.go @@ -0,0 +1,17 @@ +package replication + +// PublicationProjection is the bounded outward projection derived from one +// VolumeState. It is intentionally detached from runtime internals. +type PublicationProjection struct { + VolumeID string + Epoch uint64 + Role VolumeRole + + Mode ModeView + Publication PublicationView + Readiness ReadinessView + Boundary BoundaryView + + ReplicaIDs []string + PublishHealthy bool +} diff --git a/sw-block/engine/replication/state.go b/sw-block/engine/replication/state.go new file mode 100644 index 000000000..1e74871b7 --- /dev/null +++ b/sw-block/engine/replication/state.go @@ -0,0 +1,121 @@ +package replication + +// RuntimeAuthority names which runtime currently owns the integrated path. +// Phase 14 starts by making the V2 core explicit, but integrated tests still +// evaluate the constrained current runtime until a live cutover exists. +type RuntimeAuthority string + +const ( + RuntimeAuthorityConstrainedV1 RuntimeAuthority = "constrained_v1" + RuntimeAuthorityV2Core RuntimeAuthority = "v2_core" +) + +// VolumeRole tracks the local role the core is normalizing. +type VolumeRole string + +const ( + RoleUnknown VolumeRole = "unknown" + RolePrimary VolumeRole = "primary" + RoleReplica VolumeRole = "replica" +) + +// ModeName is the normalized outward mode vocabulary for the bounded chosen path. +type ModeName string + +const ( + ModeAllocatedOnly ModeName = "allocated_only" + ModeBootstrapPending ModeName = "bootstrap_pending" + ModeReplicaReady ModeName = "replica_ready" + ModePublishHealthy ModeName = "publish_healthy" + ModeDegraded ModeName = "degraded" + ModeNeedsRebuild ModeName = "needs_rebuild" +) + +// ReadinessView captures bounded readiness truth for the current chosen path. +type ReadinessView struct { + Assigned bool + RoleApplied bool + ReceiverReady bool + ShipperConfigured bool + ShipperConnected bool + ReplicaReady bool + PublishHealthy bool +} + +// BoundaryView captures durable/publication boundary truth. DurableLSN is the +// authority for replicated durability; diagnostic shipped progress is separate. +type BoundaryView struct { + DurableLSN uint64 + CheckpointLSN uint64 + DiagnosticShippedLSN uint64 + LastBarrierOK bool + LastBarrierReason string +} + +// ModeView is the bounded external mode meaning derived from the current state. +type ModeView struct { + Name ModeName + Reason string + Authority RuntimeAuthority +} + +// PublicationView is the outward publication truth derived from the same core +// state. It is kept separate from readiness so the publication automaton stays +// explicit in Phase 14A. +type PublicationView struct { + Healthy bool + Reason string +} + +type commandState struct { + RoleEpoch uint64 + Role VolumeRole + ReceiverStartEpoch uint64 + ShipperConfigEpoch uint64 + ShipperConfigReplicas []ReplicaAssignment + InvalidationIssued bool + InvalidationReason string +} + +// VolumeState is the minimal V2-core-owned state for one volume on the bounded +// current path. +type VolumeState struct { + VolumeID string + Epoch uint64 + Role VolumeRole + + DesiredReplicas []ReplicaAssignment + Readiness ReadinessView + Boundary BoundaryView + Mode ModeView + Publication PublicationView + + degraded bool + degradeReason string + needsRebuild bool + rebuildReason string + commands commandState +} + +func newVolumeState(volumeID string) *VolumeState { + return &VolumeState{ + VolumeID: volumeID, + Role: RoleUnknown, + Mode: ModeView{ + Name: ModeAllocatedOnly, + Authority: RuntimeAuthorityConstrainedV1, + }, + } +} + +// Snapshot returns a detached copy of the state for external inspection/tests. +func (s *VolumeState) Snapshot() VolumeState { + out := *s + if s.DesiredReplicas != nil { + out.DesiredReplicas = append([]ReplicaAssignment(nil), s.DesiredReplicas...) + } + if s.commands.ShipperConfigReplicas != nil { + out.commands.ShipperConfigReplicas = append([]ReplicaAssignment(nil), s.commands.ShipperConfigReplicas...) + } + return out +}