feat: Phase 15 + Phase 16A/B — V2 core integration + checkpoint review

Phase 15: V2 core wired into BlockService
- volume_server_block.go: v2Core field, applyCoreAssignmentEvent,
  core command executors (ApplyRole, StartReceiver, ConfigureShipper,
  InvalidateSession, StartCatchUp, StartRebuild, PublishProjection)
- Assignment processing now goes through core engine → command emission
  → bounded execution, replacing direct V1 replication setup
- master_block_registry.go: ClusterHealthSummary, VolumeMode in entries
- master_server_handlers_block.go: blockStatusHandler, entryToVolumeInfo
  refactored with entryReplicaSurface

Phase 16A: Core projection surfaces
Phase 16B: Bounded closure (checkpoint review ready)

Test fixes: add v2Core to manually-constructed BlockService in
idempotence, convergence, soak, and CP13-8A tests (required because
V1 replication setup paths now delegate to core engine).

All tests pass (21s regression).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pingqiu
2026-04-03 20:58:12 -07:00
co-authored by Claude Opus 4.6
parent a6fc8545b9
commit 8c2485e0e9
21 changed files with 4246 additions and 217 deletions
+879
View File
@@ -0,0 +1,879 @@
Purpose: append-only technical pack and delivery log for `Phase 15` adapter
hook and projection rebinding work.
---
### `15A` Technical Pack
Date: 2026-04-03
Goal: connect one narrow live path from `weed/` into the explicit `V2 core`
and one bounded command/projection path back out, without attempting broad
runtime cutover
#### Layer 1: Semantic Core
`15A` accepts one bounded thing:
1. the explicit core is no longer isolated from the integrated path
It does not accept:
1. live runtime cutover
2. registry/lookup rebinding
3. broad product-surface migration
#### Narrow path chosen
Ingress:
1. `weed/server/volume_server_block.go`
2. `BlockService.ApplyAssignments()`
Egress:
1. `PublishProjectionCommand`
2. adapter-local projection cache on `BlockService`
Reason:
1. this is the narrowest stable live path after heartbeat delivery
2. it already owns assignment apply / receiver / shipper setup
3. it allows a real in-process `weed -> core -> adapter` loop without reopening
master registry or product surfaces yet
#### `15A` Delivery Note Rev 1
Date: 2026-04-03
Scope: wire the explicit core into `BlockService.ApplyAssignments()` on one
narrow live path
What changed:
1. `BlockService` now owns an explicit `v2Core` and adapter-local core
projection cache
2. `ApplyAssignments()` now sends bounded assignment and local observation
events into the explicit core:
- `AssignmentDelivered`
- `RoleApplied`
- `ReceiverReadyObserved`
- `ShipperConfiguredObserved`
- bounded `ShipperConnectedObserved` when observable
3. `PublishProjectionCommand` now has one real egress path back into `weed/`
through the adapter-local core projection cache
Files changed:
1. `weed/server/volume_server_block.go`
- added `v2Core`
- added adapter-local projection cache
- added narrow assignment-event delivery into the explicit core
- cached `PublishProjectionCommand` output for live-path inspection
2. `weed/server/volume_server_block_test.go`
- added narrow-path proofs for replica and primary assignment delivery
3. `sw-block/.private/phase/phase-15.md`
- added phase/slice framing
Proofs added:
1. replica assignment narrow-path proof
- live `ApplyAssignments()` updates core projection cache
- resulting projection is `replica_ready`
- publication stays non-healthy with reason `replica_not_primary`
2. primary assignment narrow-path proof
- live `ApplyAssignments()` updates core projection cache
- resulting projection carries applied role and shipper-configured truth
- publication does not overclaim healthy without durable boundary closure
Validation:
1. targeted `weed/server` tests for the new narrow path
2. existing `sw-block/engine/replication` package tests stay green
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- one real adapter ingress now reaches the explicit core owner
2. overclaim avoided
- this is not broad surface rebinding
- the cache is adapter-local, not yet a product truth store
3. proof preserved
- `Phase 14` core shell remains the semantic owner
---
#### `15A` Delivery Note Rev 2
Date: 2026-04-03
Scope: prove the adapter-local projection cache does not split from the explicit
core on the narrow live path
What changed:
1. extracted adapter command egress into a dedicated helper:
- `applyCoreCommands`
2. added focused proofs that:
- adapter-local projection cache equals the explicit core projection
- repeated unchanged assignment does not make adapter cache and core diverge
Files changed:
1. `weed/server/volume_server_block.go`
- extracted command egress helper for `PublishProjectionCommand`
2. `weed/server/volume_server_block_test.go`
- strengthened replica/primary narrow-path tests with cache-vs-core equality
- added unchanged-assignment consistency proof
Proofs strengthened:
1. adapter/core projection coherence
- after live `ApplyAssignments()`, cached projection equals
`bs.V2Core().Projection(path)`
2. unchanged-assignment coherence
- repeated identical assignment keeps cache and core aligned
- repeated identical assignment does not mutate the cached outward truth
Validation:
1. `go test ./weed/server -run "TestBlockService_ApplyAssignments_(UpdatesCoreProjection|RepeatedUnchangedStaysInSyncWithCore)"`
2. `go test ./...` in `sw-block/engine/replication`
3. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the narrow adapter egress is now proven coherent with the explicit core
2. overclaim avoided
- adapter-local cache is no longer merely assumed to reflect core truth
3. proof preserved
- `15A Rev 1` ingress/egress proof remains intact
---
#### `15A` Delivery Note Rev 3
Date: 2026-04-03
Scope: make narrow-path adapter/core coherence explicitly checkable and record
the remaining semantic boundary around adapter-local `PublishHealthy`
What changed:
1. added `CoreProjectionMismatches(path)` on `BlockService`
- compares only the fields that should already agree on the narrow `15A`
path
- intentionally excludes adapter-local `ReadinessSnapshot.PublishHealthy`
2. documented that `BlockReadinessSnapshot.PublishHealthy` is still an
adapter-local bit and not the semantic owner for Phase 14 core publication
health
3. strengthened the narrow-path tests to require zero adapter/core mismatches
Files changed:
1. `weed/server/volume_server_block.go`
- added `CoreProjectionMismatches`
- clarified `BlockReadinessSnapshot.PublishHealthy` semantics
2. `weed/server/volume_server_block_test.go`
- replica narrow-path proof now asserts zero mismatches
- primary narrow-path proof now asserts zero mismatches
- repeated unchanged assignment proof now asserts zero mismatches
Proofs strengthened:
1. narrow-path aligned subset is now explicitly machine-checked
2. remaining semantic split is documented rather than hidden:
- core publication owner = `engine.PublicationView`
- adapter-local `PublishHealthy` remains a current-surface bit pending later
rebinding
Validation:
1. `go test ./weed/server -run "TestBlockService_ApplyAssignments_(UpdatesCoreProjection|RepeatedUnchangedStaysInSyncWithCore)"`
2. `go test ./...` in `sw-block/engine/replication`
3. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the narrow live path now has an explicit consistency oracle
2. overclaim avoided
- we no longer imply that all adapter-local fields are already rebound
3. proof preserved
- `15A Rev 1` and `Rev 2` proofs still pass
---
### `15B` Technical Pack
Date: 2026-04-04
Goal: make one existing `weed/` outward surface consume core-owned projection
truth instead of only adapter-local readiness bits
#### Layer 1: Semantic Core
`15B` accepts one bounded thing:
1. one real `weed/` read surface now prefers the explicit core projection when
that projection exists on the live path
It does not accept:
1. master registry rebinding
2. master lookup/public API rebinding
3. broad runtime cutover
4. removal of all adapter-local convenience state
#### Chosen surface
Surface:
1. `weed/server/volume_server_block_debug.go`
2. `/debug/block/shipper`
Reason:
1. it is an existing explicit read-only `weed/` surface
2. it already exposes readiness/publication-adjacent fields
3. it is narrow enough to rebind without reopening master or product surfaces
#### `15B` Delivery Note Rev 1
Date: 2026-04-04
Scope: rebind one VS debug surface so it consumes core-owned projection truth on
the narrow live path
What changed:
1. added `BlockService.DebugInfoForVolume(path, vol)`
- builds the outward debug view for one volume
- prefers `CoreProjection(path)` when present
- falls back to adapter-local readiness only when the core projection does
not exist yet
2. `/debug/block/shipper` now uses that helper instead of assembling the
surface directly from adapter-local readiness flags
3. the debug surface now carries bounded core-owned outward meaning:
- `mode`
- `publish_healthy`
- `publication_reason`
Files changed:
1. `weed/server/volume_server_block_debug.go`
- added `DebugInfoForVolume`
- rebound debug surface assembly to core projection
- added `mode` and `publication_reason` fields
2. `weed/server/volume_server_block_test.go`
- added primary-path proof that debug `publish_healthy` follows core
publication truth, not adapter-local convenience truth
- added replica-path proof that debug role/mode/readiness/publication align
with the cached core projection
3. `sw-block/.private/phase/phase-15.md`
- marked `15A` delivered and `15B` active
Proofs added:
1. primary-path publication overclaim blocked on the real `weed/` surface
- adapter-local readiness may still say `PublishHealthy=true`
- debug surface now reports the core-owned publication result instead
- this proves `assignment delivered != publish healthy` on the live path
2. replica-path projection rebinding
- debug role/mode/readiness/publication now match the cached core projection
- this proves one outward `weed/` surface is consuming core-owned truth
Validation:
1. `go test ./weed/server -run "TestBlockService_(ApplyAssignments|DebugInfoForVolume)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- one existing `weed/` surface now consumes explicit core projection truth
2. overclaim avoided
- this is not yet registry/lookup rebinding
- adapter-local readiness still exists as fallback and for unrebound paths
3. proof preserved
- `15A` narrow ingress/egress/cache-coherence proofs still pass
---
#### `15B` Delivery Note Rev 2
Date: 2026-04-04
Scope: rebind the VS heartbeat address-publication gate so it consumes
core-owned readiness projection instead of adapter-local `publishHealthy`
What changed:
1. `CollectBlockVolumeHeartbeat()` no longer gates scalar replica transport
addresses on adapter-local `publishHealthy` alone
2. added `heartbeatReplicaAddrs(path, state)`
- prefers `CoreProjection(path)` when present
- on primary path, heartbeat address publication follows core
`Readiness.ShipperConfigured`
- on replica path, heartbeat address publication follows core
`Readiness.ReceiverReady`
- falls back to legacy adapter-local behavior only when the core projection
does not exist yet
3. added focused differential proofs that heartbeat still reports the correct
addresses even when adapter-local `publishHealthy` is manually cleared
Files changed:
1. `weed/server/volume_server_block.go`
- rebound heartbeat scalar address publication to core readiness projection
2. `weed/server/volume_server_block_test.go`
- added primary-path heartbeat proof
- added replica-path heartbeat proof
Proofs added:
1. primary-path heartbeat rebinding
- core projection says `ShipperConfigured=true`
- core publication still remains unhealthy
- adapter-local `publishHealthy` is forcibly cleared in test
- heartbeat still reports replica addresses, proving it no longer depends on
adapter-local publication convenience truth
2. replica-path heartbeat rebinding
- core projection says `ReceiverReady=true`
- core publication remains unhealthy because replica is not the publication
owner
- adapter-local `publishHealthy` is forcibly cleared in test
- heartbeat still reports receiver addresses, proving it follows the core
readiness projection on the narrow live path
Validation:
1. `go test ./weed/server -run "TestBlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- one real report path from `weed/` to master now consumes core projection
truth
2. overclaim avoided
- heartbeat proto is not yet widened to carry full mode/publication objects
- master registry/lookup are not yet rebound
3. proof preserved
- `15B Rev 1` debug-surface rebinding still passes
---
#### `15B` Delivery Note Rev 3
Date: 2026-04-04
Scope: rebind the shared VS-side readiness snapshot so aligned fields prefer the
explicit core projection instead of adapter-local readiness state
What changed:
1. `ReadinessSnapshot(path)` now prefers `CoreProjection(path)` for the aligned
readiness subset when the narrow Phase 15 path has already produced a
projection:
- `role_applied`
- `receiver_ready`
- `shipper_configured`
- `shipper_connected`
- `replica_eligible`
2. `PublishHealthy` remains adapter-local on `ReadinessSnapshot`
- this keeps the publication ownership boundary explicit instead of silently
rebinding it through a convenience struct
3. added focused proofs that manually corrupt adapter-local readiness state and
show `ReadinessSnapshot()` still returns the core-owned aligned fields
Files changed:
1. `weed/server/volume_server_block.go`
- rebound `ReadinessSnapshot()` aligned subset to core projection
- clarified snapshot ownership boundary in comments
2. `weed/server/volume_server_block_test.go`
- added primary-path readiness snapshot proof
- added replica-path readiness snapshot proof
Proofs added:
1. primary-path shared snapshot rebinding
- adapter-local `roleApplied` and `shipperConfigured` are forcibly cleared
- `ReadinessSnapshot()` still returns them as true from the core projection
- `PublishHealthy` stays false in the snapshot, proving publication was not
silently rebound
2. replica-path shared snapshot rebinding
- adapter-local `receiverReady` and `replicaEligible` are forcibly cleared
- `ReadinessSnapshot()` still returns them as true from the core projection
- `PublishHealthy` stays false in the snapshot, preserving the ownership
boundary
Validation:
1. `go test ./weed/server -run "TestBlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the shared VS-side readiness snapshot now consumes the explicit core
projection on the narrow live path
2. overclaim avoided
- publication ownership still remains outside `ReadinessSnapshot`
- master registry/lookup are still not rebound
3. proof preserved
- `15B Rev 1` debug and `Rev 2` heartbeat rebinding proofs still pass
---
#### `15B` Delivery Note Rev 4
Date: 2026-04-04
Scope: rebind the heartbeat `replica_degraded` producer bit to the explicit core
mode and prove the master registry consume path accepts that rebinding
What changed:
1. `CollectBlockVolumeHeartbeat()` now also prefers the explicit core
projection for the bounded degraded bit
2. added `heartbeatReplicaDegraded(path, current)`
- maps `ModeDegraded` and `ModeNeedsRebuild` to heartbeat
`ReplicaDegraded=true`
- maps all other core modes to `false`
- falls back to the runtime-local status bit when no core projection exists
3. added a producer-side proof that `heartbeatReplicaDegraded(..., false)` still
returns `true` when the core projection enters `needs_rebuild`
4. added a minimal master-consume proof:
- a `BlockService` heartbeat is produced after core degraded transition
- `BlockVolumeRegistry.UpdateFullHeartbeat()` consumes that heartbeat
- registry truth becomes `TransportDegraded=true`, `ReplicaDegraded=true`,
`VolumeMode="degraded"`
Files changed:
1. `weed/server/volume_server_block.go`
- rebound heartbeat degraded bit to explicit core mode
2. `weed/server/volume_server_block_test.go`
- added bounded producer proof for core-driven degraded mapping
3. `weed/server/master_block_registry_test.go`
- added bounded consume proof for registry ingest of the core-influenced
heartbeat degraded bit
Proofs added:
1. producer degraded-bit rebinding
- replica path enters core `needs_rebuild`
- helper returns degraded even when the input `current` bit is `false`
- this proves the heartbeat producer is no longer only echoing the runtime
bit on the narrow live path
2. master consume closure
- primary path enters core `degraded`
- heartbeat exports `ReplicaDegraded=true`
- registry consume derives degraded transport and degraded volume mode from
that heartbeat
Validation:
1. `go test ./weed/server -run "Test(BlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded))"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the first bounded master-consume path now accepts a core-influenced
heartbeat bit
2. overclaim avoided
- registry mode derivation itself is not yet replaced by core-owned mode
- lookup/public API surfaces are still not rebound
3. proof preserved
- `15B Rev 1-3` VS-side rebinding proofs still pass
---
#### `15B` Delivery Note Rev 5
Date: 2026-04-04
Scope: close the other half of the first master-consume boundary by proving the
registry also consumes core-influenced ready heartbeats, not only degraded ones
What changed:
1. added a bounded ready-path consume proof in `master_block_registry_test.go`
2. the proof uses a real `BlockService` replica assignment path to produce a
heartbeat whose replica addresses still publish even after adapter-local
`publishHealthy` is manually cleared
3. `BlockVolumeRegistry.UpdateFullHeartbeat()` then consumes that heartbeat and
closes the ready half of the contract:
- replica detail becomes `Ready=true`
- aggregate `ReplicaReady=true`
- aggregate `ReplicaDegraded=false`
- normalized `VolumeMode="publish_healthy"`
Files changed:
1. `weed/server/master_block_registry_test.go`
- added `TestRegistry_UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady`
Proofs added:
1. master consume ready closure
- VS producer emits replica addresses from the core-influenced ready path
even after adapter-local publication convenience truth is cleared
- registry consume converts that heartbeat into ready aggregate truth and
`publish_healthy` outward mode
2. together with `Rev 4`, the first bounded master-consume edge now has both
sides covered:
- degraded consume
- ready consume
Validation:
1. `go test ./weed/server -run "TestRegistry_(UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the first bounded master-consume edge now has explicit proof for both ready
and degraded outcomes
2. overclaim avoided
- registry is still consuming heartbeat-derived booleans/addresses, not full
core mode/publication objects
- lookup/public API remain unrebound
3. proof preserved
- `15B Rev 4` degraded consume proof still passes unchanged
---
#### `15B` Delivery Note Rev 6
Date: 2026-04-04
Scope: extract the first explicit master-side consume helpers so registry
heartbeat semantics are no longer embedded only as inline logic inside
`UpdateFullHeartbeat()`
What changed:
1. extracted `applyPrimaryHeartbeatObservation(existing, info)`
- names the primary-heartbeat -> registry consume contract
2. extracted `applyReplicaHeartbeatObservation(existing, server, existingName, info, result)`
- names the replica-heartbeat -> registry consume contract
3. extracted `replicaReadyObservedFromHeartbeat(info)`
- makes the current ready gate explicit:
published replica receiver addresses => `Ready=true`
4. `UpdateFullHeartbeat()` now delegates to those helpers instead of carrying
the full consume mapping inline
Files changed:
1. `weed/server/master_block_registry.go`
- extracted explicit consume helpers from `UpdateFullHeartbeat()`
Proof / validation posture:
1. no new behavior claim
- this revision is an extraction/clarification step, not a semantics change
2. existing master consume proofs remain the acceptance object:
- `ReplicaReadyRequiresReplicaHeartbeat`
- `ConsumesCoreInfluencedReplicaDegraded`
- `ConsumesCoreInfluencedReplicaReady`
Validation:
1. `go test ./weed/server -run "TestRegistry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the first master consume edge is now explicit in code, not only in tests
2. overclaim avoided
- registry derivation semantics are not replaced yet
- lookup/public API are still unrebound
3. proof preserved
- `15B Rev 4-5` consume proofs still pass after extraction
---
#### `15B` Delivery Note Rev 7
Date: 2026-04-04
Scope: push the first bounded closure from master consume into an outward
master read surface
What changed:
1. extracted `entryReplicaSurfaceInfo(e, primaryAlive)` in
`master_server_handlers_block.go`
- makes the current registry -> outward surface mapping explicit for:
`ReplicaReady`, `ReplicaDegraded`, `VolumeMode`, `HealthState`
2. `entryToVolumeInfo()` now reads those outward replica-surface fields through
the helper instead of inlining them
3. added two end-to-end outward-surface proofs:
- core-influenced ready consume -> `entryToVolumeInfo()`
- core-influenced degraded consume -> `entryToVolumeInfo()`
Files changed:
1. `weed/server/master_server_handlers_block.go`
- added `entryReplicaSurfaceInfo`
- rebound `entryToVolumeInfo` to the explicit outward surface helper
2. `weed/server/master_block_observability_test.go`
- added ready-path outward closure proof
- added degraded-path outward closure proof
Proofs added:
1. ready outward closure
- VS emits a core-influenced ready heartbeat
- registry consumes it into ready aggregate truth
- `entryToVolumeInfo()` exposes:
`ReplicaReady=true`, `ReplicaDegraded=false`,
`VolumeMode=publish_healthy`, `HealthState=healthy`
2. degraded outward closure
- VS emits a core-influenced degraded heartbeat
- registry consumes it into degraded aggregate truth
- `entryToVolumeInfo()` exposes:
`ReplicaDegraded=true`, `VolumeMode=degraded`,
`HealthState=degraded`
Validation:
1. `go test ./weed/server -run "Test(Registry_(UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume))"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- one bounded master outward read path now explicitly reflects the
core-influenced consume chain
2. overclaim avoided
- this is `entryToVolumeInfo()` closure only, not full REST/gRPC surface
rebinding
- lookup/public API transport remains otherwise unchanged
3. proof preserved
- `15B Rev 4-6` producer/consume/extraction proofs remain valid
---
#### `15B` Delivery Note Rev 8
Date: 2026-04-04
Scope: close the first real HTTP handler proofs above the master outward helper
What changed:
1. added handler-level proof for `GET /block/volume/{name}`
- proves lookup handler reflects the core-influenced ready path
2. added handler-level proof for `GET /block/volumes`
- proves list handler reflects the core-influenced degraded path
3. both proofs reuse the same bounded chain already established in earlier
revisions:
- `BlockService` emits core-influenced heartbeat
- `BlockVolumeRegistry.UpdateFullHeartbeat()` consumes it
- outward handler returns the resulting truth
Files changed:
1. `weed/server/master_server_handlers_block_test.go`
- added lookup-handler ready closure proof
- added list-handler degraded closure proof
Proofs added:
1. lookup handler ready closure
- replica assignment path produces a core-influenced ready heartbeat
- registry consumes it
- `GET /block/volume/{name}` returns:
`ReplicaReady=true`, `ReplicaDegraded=false`,
`VolumeMode=publish_healthy`
2. list handler degraded closure
- primary path produces a core-influenced degraded heartbeat
- registry consumes it
- `GET /block/volumes` returns:
`ReplicaDegraded=true`, `VolumeMode=degraded`
Validation:
1. `go test ./weed/server -run "TestBlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the bounded closure now reaches real HTTP handler surfaces
2. overclaim avoided
- only two handler paths are proven so far
- gRPC lookup response remains a separate surface
3. proof preserved
- `15B Rev 7` outward helper closure remains the underlying contract
---
#### `15B` Delivery Note Rev 10
Date: 2026-04-04
Scope: extend the bounded closure from per-volume outward surfaces to the first
cluster-level aggregate outward surface
What changed:
1. added `TestBlockStatusHandler_ReflectsCoreInfluencedConsumeCounts`
2. the proof constructs two real bounded chains:
- ready path: replica-side core-influenced heartbeat -> registry consume
- degraded path: primary-side core-influenced heartbeat -> registry consume
3. `GET /block/status` is then verified to expose the resulting aggregate truth:
- `VolumeCount=2`
- `HealthyCount=1`
- `DegradedCount=1`
- `RebuildingCount=0`
- `UnsafeCount=0`
Files changed:
1. `weed/server/master_block_observability_test.go`
- added cluster-level status closure proof
Proofs added:
1. status-handler aggregate closure
- two independent core-influenced consume chains are materialized in the
registry
- `blockStatusHandler` reports the expected aggregate health counts
- this proves the bounded closure now reaches a cluster-level outward read
surface, not only per-volume lookup/list surfaces
Validation:
1. `go test ./weed/server -run "TestBlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- a cluster-level outward aggregate now reflects the same bounded
core-influenced consume chain
2. overclaim avoided
- only the status-count surface is proven here
- no broader dashboard/runbook claims are added by this revision
3. proof preserved
- `15B Rev 8-9` per-volume outward surface proofs remain valid
---
#### `15B` Delivery Note Rev 11
Date: 2026-04-04
Scope: extract the first explicit cluster-level outward response helper
What changed:
1. extracted `statusResponseFromRegistry()` from `blockStatusHandler`
2. `blockStatusHandler` now delegates to that helper instead of assembling the
aggregate response inline
3. this makes the current cluster-level outward mapping explicit for:
- volume/server counts
- promotion/barrier/queue aggregates
- healthy/degraded/rebuilding/unsafe counts
- NVMe-capable server count
Files changed:
1. `weed/server/master_server_handlers_block.go`
- added `statusResponseFromRegistry()`
- rebound `blockStatusHandler` to the helper
Proof / validation posture:
1. no new behavior claim
- this revision is a contract extraction step for the status surface
2. existing status closure proof remains the acceptance object:
- `TestBlockStatusHandler_ReflectsCoreInfluencedConsumeCounts`
Validation:
1. `go test ./weed/server -run "TestBlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the cluster-level outward aggregate now has an explicit code-level contract
2. overclaim avoided
- no new status semantics are introduced in this revision
3. proof preserved
- `15B Rev 10` status-handler closure proof still passes after extraction
---
#### `15B` Closeout Note
Date: 2026-04-04
Closeout judgment:
1. `15A` + `15B` are now treated as delivered
2. `weed/` now has one bounded integrated path where:
- core-owned events enter from the live adapter path
- bounded command/projection egress returns to the adapter
- projection/store/outward surfaces consume core-owned truth on the selected
path
Final focused validation sweep:
1. `go test ./weed/server -run "Test(BlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume)|BlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)|BlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)|LookupResponseFromEntry_PublicationMinimalSurface)"`
2. result: `PASS`
Next phase handoff:
1. move to `Phase 16`
2. stop widening surface rebinding by default
3. start replacing one adapter-owned runtime-driving path with core-driven
command ownership
---
#### `15B` Delivery Note Rev 9
Date: 2026-04-04
Scope: make the parallel gRPC lookup surface explicit as its own bounded outward
contract
What changed:
1. extracted `lookupResponseFromEntry(entry)` in
`master_grpc_server_block.go`
- this names the current `BlockVolumeEntry -> LookupBlockVolumeResponse`
mapping explicitly instead of leaving it inline inside the gRPC handler
2. `LookupBlockVolume()` now delegates to that helper
3. added a focused test that proves the helper remains a publication-minimal
outward surface:
- it returns server/transport/capacity/replica-set/durability/NVMe fields
- it does not attempt to become a second semantic owner for mode/readiness
Files changed:
1. `weed/server/master_grpc_server_block.go`
- added `lookupResponseFromEntry`
- rebound `LookupBlockVolume()` to the helper
2. `weed/server/master_grpc_server_block_test.go`
- added `TestLookupResponseFromEntry_PublicationMinimalSurface`
Proofs added:
1. gRPC lookup outward contract
- response helper preserves the current exposed fields:
`VolumeServer`, `IscsiAddr`, `CapacityBytes`,
`ReplicaServer`, `ReplicaFactor`, `ReplicaServers`,
`DurabilityMode`, `NvmeAddr`, `Nqn`
- response remains intentionally publication-minimal rather than trying to
mirror the richer HTTP mode/readiness surface
Validation:
1. `go test ./weed/server -run "Test(Master_LookupBlockVolume|LookupResponseFromEntry_PublicationMinimalSurface|Master_LookupResponse_)"`
2. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- the parallel gRPC outward path now has an explicit code-level contract
2. overclaim avoided
- gRPC lookup schema is not widened in this revision
- mode/readiness/publication truth stay on the HTTP/helper side for now
3. proof preserved
- `15B Rev 8` handler-level closures remain valid
+101
View File
@@ -0,0 +1,101 @@
# Phase 15
Date: 2026-04-03
Status: delivered
Purpose: connect the explicit `V2 core` to one narrow live adapter path so the
repo starts proving semantic ownership on the integrated path, not only inside
`sw-block/engine/replication`
## Why This Phase Exists
`Phase 14` delivered the first bounded explicit core shell:
1. `14A`: mode / readiness / publication shell closure
2. `14B`: command-sequence closure
3. `14C`: boundary / recovery closure
That shell is real, but it still mostly lives as an internal owner inside
`sw-block/engine/replication`.
`Phase 15` exists to connect one narrow live path from `weed/` into that owner
without broad rebinding or runtime cutover.
## Phase Goal
Connect one bounded adapter ingress/egress path between `weed/` and the explicit
`V2 core`, then prove the path does not silently split semantic truth.
## Scope
### In scope
1. one narrow event ingress from a live `weed/` path into the explicit core
2. one bounded command/projection egress back to the adapter layer
3. focused proof that the narrow path carries explicit core-owned truth
### Out of scope
1. no broad registry rewrite yet
2. no product-surface rebinding yet
3. no broad runtime cutover
4. no transport redesign
## Phase 15 Slices
### `15A`: Minimal Adapter Hook
Goal:
1. connect one narrow adapter ingress to the new core
Acceptance object:
1. one real event path from `weed/` into `sw-block/engine/replication`
2. one bounded command/projection path back out
3. structural proof that the narrow path updates core-owned projection truth on
the live code path
Status:
1. delivered
### `15B`: Projection-Store Rebinding
Goal:
1. make `weed/` projection/state surfaces consume core-owned projection truth
Acceptance object:
1. bounded rebinding of one or more real `weed/` surfaces to core-owned projection truth
2. proof that assignment delivered != ready != publish healthy on the real path
Current chosen paths:
1. `weed/server/volume_server_block_debug.go`
2. `/debug/block/shipper`
3. `BlockService.CollectBlockVolumeHeartbeat()`
4. `BlockVolumeRegistry.UpdateFullHeartbeat()`
5. `entryToVolumeInfo()` in `master_server_handlers_block.go`
6. `blockVolumeLookupHandler()` and `blockVolumeListHandler()`
7. `LookupBlockVolume()` in `master_grpc_server_block.go`
8. `blockStatusHandler()` aggregate counts
9. core projection preferred when present; adapter-local readiness only as fallback
Status:
1. delivered
## Immediate Next Step
Start `Phase 16` from the first bounded runtime-driving path:
1. replace one adapter-owned execution decision path with core-driven command
ownership
2. keep reusing `blockvol` as execution backend, but stop letting adapter-local
execution branching remain the semantic owner
This is the next natural step after `15B`: outward surfaces now consume
core-owned truth on a bounded path; `Phase 16` must make one bounded integrated
runtime path behave as a `V2`-owned runtime rather than constrained-`V1`
semantics plus rebinding.
@@ -0,0 +1,155 @@
# Phase 16 Checkpoint Review
Date: 2026-04-04
Status: ready for review
## Review Object
Review the current bounded checkpoint as:
1. `Phase 15` delivered
2. `16A` delivered
3. `16B` current bounded closure
This checkpoint should be judged as the first bounded integrated runtime
checkpoint after `Phase 15` closeout.
## What Is In Scope
### `Phase 15` closeout
1. bounded surface/store/outward consume-chain rebinding to core-owned truth
2. cluster-level status surface extraction and closure proof preserved
### `16A` delivered
Bounded command-driven adapter ownership now covers:
1. `apply_role`
2. `start_receiver`
3. `configure_shipper`
4. `invalidate_session`
Expected judgment:
1. these paths execute because the core emitted commands
2. the adapter is executor, not semantic owner
### `16B` current bounded closure
Bounded live recovery closure now covers:
1. live recovery observations return into the core on catch-up / rebuild
entry/exit points
2. bounded catch-up execution runs from `StartCatchUpCommand`
3. old no-core path compatibility remains preserved
Expected judgment:
1. this is a real bounded runtime closure step
2. it is not yet full recovery-loop ownership
## What Is Explicitly Out Of Scope
Do NOT review this checkpoint as claiming:
1. `start_rebuild` execution ownership
2. full rebuild runtime closure
3. full recovery-loop closure
4. broad multi-replica runtime ownership
5. launch / rollout readiness
## Primary Files
Phase tracking:
1. `sw-block/.private/phase/phase-15.md`
2. `sw-block/.private/phase/phase-15-log.md`
3. `sw-block/.private/phase/phase-16.md`
4. `sw-block/.private/phase/phase-16-log.md`
Integrated runtime code:
1. `weed/server/volume_server_block.go`
2. `weed/server/volume_server_block_test.go`
3. `weed/server/master_server_handlers_block.go`
4. `weed/server/master_block_observability_test.go`
5. `weed/server/block_recovery.go`
6. `weed/server/block_recovery_test.go`
## Evidence Summary
### Surface/store closure preserved
Focused proof suite:
1. `go test ./weed/server -run "Test(BlockService_(ApplyAssignments|BarrierRejected|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume)|BlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)|BlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)|LookupResponseFromEntry_PublicationMinimalSurface)"`
2. result: `PASS`
### Recovery closure
Focused recovery proof suite:
1. `go test ./weed/server -run "TestP(4_LivePath_RealVol_ReachesPlan|16B_RunCatchUp_)"`
2. result: `PASS`
## Review Questions
### For `sw`
Please check implementation correctness and commit-readiness:
1. Is the suggested commit boundary coherent as one checkpoint?
2. Are the file changes internally consistent for:
- `Phase 15` closeout
- `16A` delivered
- `16B` current closure
3. Are there any obvious cleanup/refactor issues that should be fixed before
commit, without broadening scope?
Suggested commit boundary if accepted:
1. `sw-block/.private/phase/phase-15.md`
2. `sw-block/.private/phase/phase-15-log.md`
3. `sw-block/.private/phase/phase-16.md`
4. `sw-block/.private/phase/phase-16-log.md`
5. `weed/server/volume_server_block.go`
6. `weed/server/volume_server_block_test.go`
7. `weed/server/master_server_handlers_block.go`
8. `weed/server/master_block_observability_test.go`
9. `weed/server/block_recovery.go`
10. `weed/server/block_recovery_test.go`
### For `tester`
Please challenge the proof posture:
1. Does `16A` really prove command-driven ownership, or only show refactored
call placement?
2. Does `16B Rev 2` really prove `start_catchup` is command-driven on the live
path?
3. Are there any remaining surfaces where adapter-local truth could still
contradict the core on the bounded path?
4. Are any of the current tests proving implementation shape only, rather than
semantic claim?
### For `manager`
Please challenge boundaries and overclaim:
1. Are `16A` and `16B` still cleanly separated?
2. Is `16B Rev 2` still a bounded catch-up slice, rather than silently becoming
full recovery-loop closure?
3. Does the checkpoint wording stay disciplined about what is NOT yet claimed?
4. Is the proposed commit boundary a good stage checkpoint?
## Requested Output Shape
Please reply with one of:
1. `ACCEPT`
2. `ACCEPT WITH MINOR FIXES`
3. `REJECT`
If not `ACCEPT`, list findings ordered by severity and keep them bounded to this
checkpoint's actual claim set.
+429
View File
@@ -0,0 +1,429 @@
Purpose: append-only technical pack and delivery log for `Phase 16`
`V2`-native runtime closure work.
---
### `16A` Technical Pack
Date: 2026-04-04
Goal: replace one adapter-owned execution decision path with explicit
core-driven command ownership while keeping `blockvol` as the execution backend
#### Layer 1: Semantic Core
`16A` accepts one bounded thing:
1. one real integrated execution path now runs because the core emitted the
command, not because adapter-local branching independently decided it
It does not accept:
1. full replacement of `blockvol` async runtime loops
2. broad runtime cutover across all adapter paths
3. all recovery/failover paths becoming `V2`-native at once
#### Candidate runtime-driving paths
Candidate commands already emitted by the explicit core:
1. `apply_role`
2. `start_receiver`
3. `configure_shipper`
4. `start_catchup`
5. `start_rebuild`
Selection rule:
1. prefer the narrowest path that is already real on the integrated `weed/`
path
2. prefer a path where current execution is still primarily adapter-owned
3. prefer a path with direct proofable observation back into the core
#### Initial implementation rule
For the first `16A` slice:
1. do not rewrite `blockvol` internals
2. do not change flusher/shipper goroutine architecture yet
3. move runtime-driving ownership one step upward:
- core emits command
- adapter executes command
- execution observation returns as explicit core event
#### Validation target
The first accepted `16A` path must prove:
1. the command was emitted by the core
2. the adapter executed because of that command
3. the resulting observation fed back into the core
4. outward surfaces stayed coherent with that same path
---
#### `16A` Delivery Note Rev 1
Date: 2026-04-04
Scope: first bounded runtime-driving command path on the integrated adapter
What changed:
1. `ApplyAssignments()` no longer performs role application as adapter-local
branching when the explicit core is present
2. assignment delivery now executes `apply_role` from core command egress
3. replica assignment path also executes `start_receiver` from the same bounded
command path
4. `publish_projection` caching now prefers the latest core projection so
command-chain observations cannot be overwritten by an older inline
projection snapshot
Files changed:
1. `weed/server/volume_server_block.go`
- role application moved to `ApplyRoleCommand` execution
- replica receiver startup moved to `StartReceiverCommand` execution
- added bounded executed-command trace for focused runtime-ownership proofs
- projection cache now prefers latest `v2Core.Projection()`
2. `weed/server/volume_server_block_test.go`
- added focused proof for primary `apply_role` command ownership
- added focused proof for replica `apply_role + start_receiver` ownership
Bounded contract:
`16A Rev 1` accepts only this:
1. one integrated assignment path now executes from core command egress
2. the adapter remains the executor, not the semantic owner
3. outward/readiness projections remain aligned after the command chain
It does not yet accept:
1. primary shipper configuration becoming fully core-command-driven
2. rebuild / catch-up / failover runtime closure
3. broad `Phase 16` completion
Validation:
1. `go test ./weed/server -run "TestBlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)"`
2. `go test ./weed/server -run "Test(BlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume)|BlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)|BlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)|LookupResponseFromEntry_PublicationMinimalSurface)"`
3. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- `Phase 16` requires one bounded path where core-owned semantics drive
adapter execution rather than adapter-local truth
2. overclaim avoided
- this revision does not claim full async runtime replacement or full
failover/rebuild closure
3. proof preserved
- existing `15A/15B` projection/store closure tests still pass after the new
command-driven assignment path
---
#### `16A` Delivery Note Rev 2
Date: 2026-04-04
Scope: extend the same bounded assignment-side command path to primary shipper
configuration
What changed:
1. `ApplyAssignments()` no longer configures primary replication directly when
the explicit core is present
2. primary shipper wiring now executes only from
`engine.ConfigureShipperCommand`
3. the executed-command trace now proves a full bounded assignment-side command
chain:
- primary: `apply_role -> configure_shipper`
- replica: `apply_role -> start_receiver`
Files changed:
1. `weed/server/volume_server_block.go`
- added `executeConfigureShipperCommand()`
- removed adapter-local primary replication setup from `ApplyAssignments()`
2. `weed/server/volume_server_block_test.go`
- strengthened primary command-ownership proof to require
`apply_role + configure_shipper`
Bounded contract:
`16A Rev 2` accepts only this:
1. the full assignment-side command chain is now core-command-driven on the
integrated path
2. primary and replica setup no longer rely on adapter-local branching for the
bounded assignment path
3. existing projection/store/outward-surface proofs remain intact
It does not yet accept:
1. catch-up runtime ownership
2. rebuild runtime ownership
3. invalidation / recovery-loop runtime closure
Validation:
1. `go test ./weed/server -run "TestBlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)"`
2. `go test ./weed/server -run "Test(BlockService_(ApplyAssignments|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume)|BlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)|BlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)|LookupResponseFromEntry_PublicationMinimalSurface)"`
3. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- `Phase 16` requires one bounded runtime path where command emission from
the explicit core drives adapter execution
2. overclaim avoided
- this revision still does not claim recovery-side runtime closure
3. proof preserved
- `Phase 15B` surface consume-chain proofs remain green after moving primary
shipper setup behind core command ownership
---
#### `16A` Delivery Note Rev 3
Date: 2026-04-04
Scope: first bounded recovery-side command path on the integrated runtime
What changed:
1. `InvalidateSessionCommand` is now executed on the live adapter path
2. when the core emits invalidation for a volume, the adapter invalidates the
corresponding integrated sender sessions through the orchestrator registry
3. repeated identical failure transitions still remain bounded:
one new failure transition triggers one invalidation command; repeated same
failure does not re-execute it
Files changed:
1. `weed/server/volume_server_block.go`
- added `executeInvalidateSessionCommand()`
- wired `InvalidateSessionCommand` into command execution
2. `weed/server/volume_server_block_test.go`
- added proof that `BarrierRejected` invalidates an active sender session
- added proof that repeated same-reason rejection does not re-execute
invalidation
Bounded contract:
`16A Rev 3` accepts only this:
1. one integrated failure-side path is now also core-command-driven
2. sender-session invalidation on the bounded path no longer depends on
adapter-local branching alone
3. assignment-side command ownership and outward surface closure remain intact
It does not yet accept:
1. catch-up execution ownership
2. rebuild execution ownership
3. full recovery-loop runtime closure
Validation:
1. `go test ./weed/server -run "TestBlockService_(ApplyAssignments|BarrierRejected|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)"`
2. `go test ./weed/server -run "Test(BlockService_(ApplyAssignments|BarrierRejected|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume)|BlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)|BlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)|LookupResponseFromEntry_PublicationMinimalSurface)"`
3. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- `Phase 14` command semantics already required invalidation only on a new
failure transition; `Phase 16` now makes that command real on the bounded
integrated path
2. overclaim avoided
- this revision invalidates sessions, but does not yet claim catch-up or
rebuild execution ownership
3. proof preserved
- `15B` projection/store/outward-surface proofs remain green after adding the
failure-side command path
---
#### `16A` Closeout Note
Date: 2026-04-04
Closeout judgment:
1. `16A` is treated as delivered
2. the bounded integrated command-driven path now includes:
- `apply_role`
- `start_receiver`
- `configure_shipper`
- `invalidate_session`
3. this is enough to say one real runtime-driving command chain is now
core-owned on the integrated path
Residual non-claims:
1. active recovery execution (`start_catchup`, `start_rebuild`) is not yet
core-command-driven
2. `16A` does not close the full recovery loop by itself
---
#### `16B` Delivery Note Rev 1
Date: 2026-04-04
Scope: first live recovery observation path back into the explicit core
What changed:
1. `RecoveryManager.runCatchUp()` now feeds live recovery planning/escalation
back into the core:
- `CatchUpPlanned`
- `NeedsRebuildObserved`
- `CatchUpCompleted`
2. `RecoveryManager.runRebuild()` now feeds live rebuild lifecycle milestones
back into the core:
- `RebuildStarted`
- `RebuildCommitted`
3. this makes the integrated recovery path update core-owned recovery/boundary
truth instead of leaving those states test-only or documentation-only
Files changed:
1. `weed/server/block_recovery.go`
- emit recovery events into `v2Core` from live catch-up/rebuild execution
2. `weed/server/block_recovery_test.go`
- added focused proof that live catch-up updates core projection boundary
- added focused proof that live needs-rebuild escalation updates core mode
Bounded contract:
`16B Rev 1` accepts only this:
1. one live recovery observation path now closes back into the core
2. core-owned boundary/recovery/mode fields update from real recovery work
3. existing `15B/16A` surface and command closure remain intact
It does not yet accept:
1. `start_catchup` command execution ownership
2. `start_rebuild` command execution ownership
3. full recovery-loop execution becoming core-command-driven
Validation:
1. `go test ./weed/server -run "TestP(4_LivePath_RealVol_ReachesPlan|16B_RunCatchUp_)"`
2. `go test ./weed/server -run "Test(P4_|P16B_|BlockService_(ApplyAssignments|BarrierRejected|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume)|BlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)|BlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)|LookupResponseFromEntry_PublicationMinimalSurface)"`
3. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- `Phase 16` requires runtime observations to close back into core-owned
truth instead of remaining adapter-local/runtime-local facts
2. overclaim avoided
- this revision does not yet claim that recovery execution itself is
core-command-driven
3. proof preserved
- `15B` outward consume-chain proofs and `16A` command-driven proofs remain
green after adding recovery event ingress
---
#### `16B` Delivery Note Rev 2
Date: 2026-04-04
Scope: bounded catch-up execution ownership on the live recovery path
What changed:
1. `RecoveryManager.runCatchUp()` no longer directly executes the catch-up plan
when the explicit core is present
2. catch-up plans are now cached as bounded pending executions and consumed only
from `StartCatchUpCommand`
3. if the core does not emit/consume `StartCatchUpCommand`, the pending plan is
cancelled fail-closed instead of executing implicitly
4. compatibility is preserved for the old no-core path:
legacy `P4` recovery tests still execute directly when `v2Core` is absent
Files changed:
1. `weed/server/block_recovery.go`
- added bounded pending recovery execution cache
- `runCatchUp()` now plans + emits core event, then waits for command
consumption on the core-present path
- added `ExecutePendingCatchUp()` and shared execution helpers
2. `weed/server/volume_server_block.go`
- wired `StartCatchUpCommand` into command execution
3. `weed/server/block_recovery_test.go`
- strengthened catch-up proof to require live `start_catchup` execution
- preserved old `P4` live-path proof
Bounded contract:
`16B Rev 2` accepts only this:
1. one active recovery execution path (`catch-up`) is now core-command-driven on
the integrated runtime
2. the corresponding live recovery observations still return back into the core
3. old no-core compatibility remains preserved for previously accepted tests
It does not yet accept:
1. `start_rebuild` execution ownership
2. full recovery-loop execution closure
3. broad multi-replica recovery ownership beyond the current bounded path
Validation:
1. `go test ./weed/server -run "TestP(4_LivePath_RealVol_ReachesPlan|16B_RunCatchUp_)"`
2. `go test ./weed/server -run "Test(P4_|P16B_|BlockService_(ApplyAssignments|BarrierRejected|DebugInfoForVolume|CollectBlockVolumeHeartbeat|ReadinessSnapshot|HeartbeatReplicaDegraded)|Registry_(ReplicaReadyRequiresReplicaHeartbeat|UpdateFullHeartbeat|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded|UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady)|EntryToVolumeInfo_(IncludesHealthState|ReflectsCoreInfluencedReadyConsume|ReflectsCoreInfluencedDegradedConsume)|BlockVolume(LookupHandler_ReflectsCoreInfluencedReadyConsume|ListHandler_ReflectsCoreInfluencedDegradedConsume)|BlockStatusHandler_(IncludesHealthCounts|ReflectsCoreInfluencedConsumeCounts)|LookupResponseFromEntry_PublicationMinimalSurface)"`
3. result: `PASS`
Constraint / overclaim / proof review:
1. semantic constraint satisfied
- `Phase 16` requires not only observation closure, but also one bounded
active recovery execution path to run because the core emitted the command
2. overclaim avoided
- only catch-up is moved behind core command ownership in this revision;
rebuild remains explicitly outside the accepted closure
3. proof preserved
- accepted `P4` no-core recovery tests still pass, and `15B/16A/16B` bounded
consume-chain proofs remain green
---
#### `16` Checkpoint Review Note
Date: 2026-04-04
Current checkpoint judgment:
1. this is the first bounded integrated runtime checkpoint after `Phase 15`
closeout
2. `16A` is delivered
3. `16B` has one accepted current closure:
- live recovery observations close back into the core
- bounded catch-up execution is core-command-driven
Recommended review target:
1. review this checkpoint as:
- `Phase 15 delivered`
- `16A delivered`
- `16B current bounded closure`
2. do not review it as:
- full rebuild execution ownership
- full recovery-loop closure
- launch / rollout readiness
Suggested commit boundary if review is accepted:
1. `sw-block/.private/phase/phase-15.md`
2. `sw-block/.private/phase/phase-15-log.md`
3. `sw-block/.private/phase/phase-16.md`
4. `sw-block/.private/phase/phase-16-log.md`
5. `weed/server/volume_server_block.go`
6. `weed/server/volume_server_block_test.go`
7. `weed/server/master_server_handlers_block.go`
8. `weed/server/master_block_observability_test.go`
9. `weed/server/block_recovery.go`
10. `weed/server/block_recovery_test.go`
+139
View File
@@ -0,0 +1,139 @@
# Phase 16
Date: 2026-04-04
Status: active
Purpose: close one bounded `V2`-native runtime path where the explicit core
owns runtime-driving semantics and `blockvol` remains only the execution backend
## Why This Phase Exists
`Phase 14` made the explicit core real.
`Phase 15` then rebound one bounded set of integrated `weed/` surfaces so they
consume core-owned truth instead of silently inheriting adapter-local semantics.
That means the repo now has:
1. explicit core-owned state / command / projection semantics
2. bounded integrated surface rebinding across VS, registry, HTTP, gRPC, and
cluster status
But it still does not yet have one bounded path where runtime-driving execution
ownership itself is `V2`-native.
## Phase Goal
Close one bounded integrated runtime path where:
1. the explicit core decides the runtime-driving command sequence
2. the adapter executes those commands against `blockvol`
3. runtime observations return back into the core
4. outward surfaces continue to reflect that core-owned runtime path
## Scope
### In scope
1. one bounded command-driven adapter execution path
2. one bounded observation-feedback path from execution back into the core
3. end-to-end proof that the bounded path behaves as a `V2`-owned runtime path
### Out of scope
1. no full replacement of all `blockvol` async executors
2. no broad runtime cutover across every `weed/` path
3. no protocol rediscovery
4. no launch / rollout approval
## Phase 16 Slices
### `16A`: Command-Driven Adapter Ownership
Goal:
1. replace one adapter-owned execution decision path with core-driven command
ownership
Acceptance object:
1. one real integrated path executes because the core emitted the command
2. the adapter no longer decides that path only from its local branching
3. proof that command emission and command execution stay aligned
Current chosen path:
1. assignment-driven `apply_role` execution now runs from core command egress
2. replica-path `start_receiver` execution follows the same bounded command path
3. primary-path `configure_shipper` execution now also follows the bounded
command path
4. failure-side `invalidate_session` execution now follows the bounded command
path for the integrated sender path
5. catch-up / rebuild remain outside the current `16A` closure
Status:
1. delivered
### `16B`: Runtime Observation Closure
Goal:
1. make the bounded runtime path close back into the core through explicit
observation semantics
Acceptance object:
1. one end-to-end failover/recovery/publication scenario runs on the
core-driven path
2. proof that outward surfaces remain consistent with the same bounded runtime
path
Current chosen path:
1. live recovery observations now return into the core on catch-up and rebuild
entry/exit points
2. bounded catch-up execution now runs from `StartCatchUpCommand`
3. rebuild execution is the next likely runtime-driving candidate on the same
path
Status:
1. active
## Current Checkpoint Review Target
The current review target is the first bounded integrated runtime checkpoint
after `Phase 15` closeout:
1. `Phase 15` delivered:
- bounded surface/store/outward consume-chain rebinding
2. `16A` delivered:
- bounded command-driven adapter ownership for:
- `apply_role`
- `start_receiver`
- `configure_shipper`
- `invalidate_session`
3. `16B` active with accepted current closure:
- live recovery observations return into the core
- bounded catch-up execution runs from `StartCatchUpCommand`
This checkpoint is intentionally still bounded:
1. `start_rebuild` execution ownership is not yet in scope
2. broad recovery-loop closure is not yet claimed
3. launch / rollout readiness is not claimed
## Immediate Next Step
Start `16A` with the narrowest runtime-driving execution decision still owned by
adapter branching:
1. choose one path where the core already emits a bounded command
2. let the adapter execute from that command instead of from implicit local
control flow
3. keep `blockvol` as execution backend and treat its local state only as
observation input
The next likely engineering target is the matching rebuild execution path,
unless the current `Phase 15 + 16A + 16B` boundary is accepted as the next
commit checkpoint first.
+432 -10
View File
@@ -429,13 +429,15 @@ Evidence anchor:
## Current Strongest Evidence By Layer
| Layer | Main value |
|------|------------|
| `FSM` / design | define truth and non-goals |
| simulator | prove protocol truth and failure-class closure cheaply |
| prototype | prove implementation-shape and authority semantics cheaply |
| engine | prove the accepted contracts survive real implementation structure |
| service slice / runner | prove truth survives real control/storage/system reality |
| Layer | Main value |
| ---------------------- | ------------------------------------------------------------------ |
| `FSM` / design | define truth and non-goals |
| simulator | prove protocol truth and failure-class closure cheaply |
| prototype | prove implementation-shape and authority semantics cheaply |
| engine | prove the accepted contracts survive real implementation structure |
| service slice / runner | prove truth survives real control/storage/system reality |
## Phase Conformance Notes
@@ -594,16 +596,20 @@ Main review risk:
When a later feature expands the protocol surface (for example `SmartWAL` or a new rebuild optimization), the order should be:
1. `FSM / design`
- define the new semantics and non-goals
2. `Truth update`
1. `Truth update`
- either attach the feature to an existing truth
- or add a new protocol truth if the feature creates a new long-lived invariant
3. `Phase alignment`
1. `Phase alignment`
- define which later phases strengthen or validate that truth
4. `Evidence ladder`
1. `Evidence ladder`
- simulator, prototype, engine, service slice as needed
Do not start feature implementation by editing engine or service glue first and only later trying to explain what truth changed.
@@ -618,3 +624,419 @@ For any future feature, later reviews should ask:
4. does the feature weaken any existing truth?
This keeps feature growth aligned with protocol truth instead of letting implementation convenience define semantics.
## 继承图
最短可以看成三层:
```text
Layer 1: 已接受的语义与证据
-----------------------------------------
Phase 08-13
-> protocol truths
-> claim / evidence
-> envelope / non-claim
-> bounded proofs
-> V1-under-V2-constraints test results
||
|| 继承“语义约束、证明边界、不能 overclaim 的规则”
\/
Layer 2: V2 core 原型与显式自动机
-----------------------------------------
Phase 14
-> small automata
- assignment
- recovery
- boundary
- mode
- publication
-> explicit state owner
-> transition rules
-> command emission rules
-> projection contracts
||
|| 继承“状态机结构、命令规则、对外投影规则”
\/
Layer 3: 集成运行时
-----------------------------------------
Phase 15-16
-> weed/ adapter
-> blockvol executor
-> master / VS / heartbeat / lookup / debug / status
-> real command-driven path
-> real observation feedback path
目标:
V2 core owns semantics
adapter translates
blockvol executes
```
如果换成“什么被继承、什么不被继承”,就是这个图:
```text
前面 phase 资产
|
|-- 继承
| |-- truth constraints
| |-- FSM/state-transition meaning
| |-- command emission conditions
| |-- projection/publication rules
| |-- accepted evidence and non-claims
|
|-- 不直接继承
| |-- V1 runtime 的状态所有权
| |-- V1 内联控制分支就是最终 truth
| |-- “测试跑通了所以产品语义成立” 这种隐式结论
|
v
Phase 14 core
|
|-- 输出
| |-- explicit core engine
| |-- owned state
| |-- explicit commands
| |-- explicit projections
|
v
Phase 15-16 integration
|
|-- 复用
| |-- blockvol flusher
| |-- shipper / receiver
| |-- rebuild server
| |-- existing transport/backend machinery
|
|-- 替换/上提
| |-- 谁决定 apply_role
| |-- 谁决定 start_receiver
| |-- 后面继续上提 configure_shipper 等 runtime-driving decision
```
再给你一个“现在走到哪里”的定位图:
```text
[Phase 08-13]
约束 / 证据 / envelope / prototype input
|
v
[Phase 14]
V2 core shell 成立
|
v
[Phase 15]
surface consume core truth
|
v
[Phase 16]
runtime-driving decisions move from adapter -> core
|
v
[未来]
one bounded V2-native runtime path
```
一句话总结:
- `Phase 08-13` 留下的是“规则和证据”
- `Phase 14` 把它们变成“显式 core”
- `Phase 15-16` 把 core 接到真实 runtime,上提语义控制权
- `V1` 主要保留为执行 backend,不再保留为语义 owner
## 逻辑
可以。我换成更“工程视角”的讲法,不只是图,而是讲这套继承到底怎么落到代码和后续开发上。
### 核心思路
这个项目现在不是一条线直接从“协议文档”走到“最终产品”,而是四层:
1. `truth / claim / envelope`
2. `core semantic model`
3. `adapter integration`
4. `backend execution reuse`
它们的关系不是谁替代谁,而是“上层定义意义,下层负责承载”。
最重要的一点是:
- 上层决定“什么算真”
- 下层只负责“怎么做出来”
这就是为什么我一直在强调:
`V2 owns semantics, V1/blockvol owns execution`
### 四层继承图
```text
Layer A: 语义约束层
--------------------------------
sw-block/design/v2-protocol-truths.md
sw-block/design/v2-protocol-claim-and-evidence.md
sw-block/design/v2-protocol-closure-map.zh.md
定义:
- 什么叫 assigned / ready / durable / degraded / publish healthy
- 哪些 claim 已经接受
- 哪些 envelope 是允许宣称的
- 哪些 non-claim 不能偷着扩张
||
|| 把“正确性的标准”传下去
\/
Layer B: V2 core 语义层
--------------------------------
sw-block/design/v2_mini_core_design.md
sw-block/design/v2-phase14plus-semantic-framework.md
sw-block/engine/replication/
定义:
- 小自动机怎么拆
- state 谁拥有
- transition 怎么发生
- command 什么时候发
- projection 允许怎么对外表达
||
|| 把“标准”变成“可执行语义模型”
\/
Layer C: Adapter 集成层
--------------------------------
weed/server/...
master / volume server / heartbeat / lookup / status / debug
职责:
- 把 live assignment / heartbeat / observation 送进 core
- 把 core 的 command / projection 接回真实系统
- 对外 surface 尽量只消费 core truth
||
|| 把 core 接到真实运行时
\/
Layer D: Backend 执行层
--------------------------------
weed/storage/blockvol/...
职责:
- flusher
- shipper
- receiver
- rebuild server
- checkpoint / WAL / async execution
只负责:
- 执行
- 持久化
- 传输
- 提供 observation
不负责:
- 最终语义定义
```
### 每层分别继承了什么
#### 1. 从前面 phases 继承了什么
前面 `Phase 08-13` 不是白做的,它们沉淀了三类资产:
1. 语义定义
2. 证明边界
3. 已接受证据
比如:
- durable progress 该看什么,不该看什么
- publication healthy 不能等同于 replica ready
- degraded / needs_rebuild 不能乱宣称
- 哪些测试只是 witness,哪些已经是 proof
- 哪些 envelope 是当前 chosen path,不能擅自放大成“通用产品能力”
所以现在做 `Phase 15/16`,不是重新设计世界,而是在问:
- 这个 live path 是否遵守了已经接受的 truth?
- 这个 adapter 是否把语义 owner 放错地方了?
- 这个 surface 是否产生 overclaim
#### 2. `Phase 14` 继承并固化了什么
`Phase 14` 的价值,不是“写了一个新包”,而是把前面松散的规则固化成 core 结构。
它固定了四件事:
1. state owner
2. transition rule
3. command emission rule
4. projection contract
这意味着后面就不是“看到一个 bug 再 patch 一层”,而是可以用统一问题来判断:
- 这是 state 问题?
- 这是 transition 问题?
- 这是 command 发错时机?
- 还是 projection overclaim
这就是原来你说的那种目标:
“以后每个算法选择都能回答:满足哪个语义约束,避免哪个 overclaim,保住哪个 proof。”
#### 3. `Phase 15` 继承了什么
`Phase 15` 不是在发明新语义,而是在做一个很重要的事情:
`weed/` 的真实 surface 开始消费 core-owned truth。
也就是把过去散落在 adapter 里的这些东西逐步收回:
- debug
- heartbeat
- readiness snapshot
- master registry consume
- lookup/list/status outward surface
`Phase 15` 的主题不是 runtime control,而是 surface rebinding。
意思是:
- 先不急着改谁驱动执行
- 先把谁有资格对外说话这件事收紧
所以 `Phase 15` 更像是:
“让嘴巴先归 core 管”
#### 4. `Phase 16` 继承了什么
`Phase 16` 才开始继续向下推进,去拿“手脚”。
也就是从:
- core 只是解释系统状态
- adapter 还在自己决定很多执行动作
变成:
- core 发 command
- adapter 只是执行 command
- backend 执行后返回 observation
- core 再更新 state / projection
这就是为什么我刚刚在 `16A` 先把:
- `apply_role`
- `start_receiver`
这两条路径接成 command-driven。
这一步的意义不在于“功能变多了”,而在于 runtime ownership 往上提了。
### 一张更贴 repo 的图
```text
v2-protocol-truths.md
v2-protocol-claim-and-evidence.md
v2-protocol-closure-map.zh.md
|
v
v2_mini_core_design.md
v2-phase14plus-semantic-framework.md
|
v
sw-block/engine/replication/
- state
- event
- command
- projection
|
v
weed/server/
- ApplyAssignments()
- heartbeat consume / produce
- lookup/list/status/debug
|
v
weed/storage/blockvol/
- WAL
- flusher
- shipper
- receiver
- rebuild
```
可以把它理解成:
- 文档定义语义世界
- `engine/replication` 把语义世界程序化
- `weed/server` 把程序化语义接到 live system
- `blockvol` 提供真正的执行能力
### 为什么要这样分,而不是继续修 V1
这是你前面感觉最敏锐的地方:
如果一直在 `V1` 上补测试、补约束、补文档,最后容易变成:
- 我们越来越懂 `V1`
- 但没有真正得到一个 `V2` owner
那样后面每加一个功能都要重新回答:
“这次到底是谁说了算?”
现在这种分层,就是为了避免未来每做一次事都重新付出一大轮认知成本。
理想结果是:
- 新功能先接到 core 语义层
- adapter 只做接线
- backend 只做执行
- proofs 可以复用
- envelope 不需要每次重谈
也就是说,前期看起来“多做了一层”,但长期是为了让后面的功能开发更便宜。
### 你可以怎么理解现在的进度
如果用一句最直白的话:
- `Phase 08-13`:知道什么是对的,并拿到了很多边界证据
- `Phase 14`:把“什么是对的”变成显式 core
- `Phase 15`:让对外说法开始归 core
- `Phase 16`:让真实执行决策开始归 core
所以现在不是从零开始,也不是还在原地测 `V1`
而是在把以前的知识资产,逐步变成真正可接管 runtime 的结构。
### 你后面看代码时可以用的判断法
你如果想快速判断某一段代码属于哪一层,可以问这三个问题:
1. 它是在定义“什么是真的”吗?
如果是,那它应该靠近 `design``engine/replication`
2. 它是在把外部输入翻译给 core,或者把 core 输出翻回系统吗?
如果是,那它应该在 `weed/server`
3. 它是在真正写 WAL、flush、ship、rebuild、listen socket 吗?
如果是,那它应该属于 `blockvol` 执行层,不该拥有最终语义
这个判断法很有用,因为以后 review 时会一直遇到“这段逻辑该放哪里”。
## 当前最关键的设计纪律
现在最需要守住的纪律其实就两条:
1. 不让 `V1` 本地状态重新成为 semantic owner
2. 不让新的 outward surface 绕开 core 再发明一套 truth
只要守住这两条,前面 phases 的资产就不是散掉,而是在持续被吸收到最终架构里。
如果你想,我下一条可以继续讲一个更具体的话题:
1. “五个小自动机分别管什么”
2. “为什么 `publication` 必须单独拥有 authority”
3. “为什么 `blockvol` 的异步线程还能保留,但 semantic ownership 不能保留”
你选一个,我可以继续往下展开。
+182 -33
View File
@@ -31,19 +31,29 @@ type recoveryTask struct {
type RecoveryManager struct {
bs *BlockService
mu sync.Mutex
tasks map[string]*recoveryTask
wg sync.WaitGroup
mu sync.Mutex
tasks map[string]*recoveryTask
pending map[string]*pendingRecoveryExecution
wg sync.WaitGroup
// TestHook: if set, called before execution starts. Tests use this
// to hold the goroutine alive for serialized-replacement proofs.
OnBeforeExecute func(replicaID string)
}
type pendingRecoveryExecution struct {
volumeID string
replicaID string
driver *engine.RecoveryDriver
plan *engine.RecoveryPlan
io *v2bridge.Executor
}
func NewRecoveryManager(bs *BlockService) *RecoveryManager {
return &RecoveryManager{
bs: bs,
tasks: make(map[string]*recoveryTask),
bs: bs,
tasks: make(map[string]*recoveryTask),
pending: make(map[string]*pendingRecoveryExecution),
}
}
@@ -130,6 +140,12 @@ func (rm *RecoveryManager) Shutdown() {
}
}
rm.tasks = make(map[string]*recoveryTask)
for volumeID, pending := range rm.pending {
if pending != nil && pending.driver != nil && pending.plan != nil {
pending.driver.CancelPlan(pending.plan, "recovery_shutdown")
}
delete(rm.pending, volumeID)
}
rm.mu.Unlock()
rm.wg.Wait()
}
@@ -249,25 +265,43 @@ func (rm *RecoveryManager) runCatchUp(ctx context.Context, replicaID, rebuildAdd
glog.Warningf("recovery: plan failed for %s: %v", replicaID, err)
return
}
switch plan.Outcome {
case engine.OutcomeCatchUp:
if bs.v2Core == nil {
if err := rm.executeCatchUpPlan(volPath, replicaID, driver, plan, executor); err != nil {
if ctx.Err() != nil {
glog.V(1).Infof("recovery: catch-up cancelled for %s: %v", replicaID, err)
} else {
glog.Warningf("recovery: catch-up execution failed for %s: %v", replicaID, err)
}
}
return
}
rm.storePendingExecution(volPath, &pendingRecoveryExecution{
volumeID: volPath,
replicaID: replicaID,
driver: driver,
plan: plan,
io: executor,
})
bs.applyCoreEvent(engine.CatchUpPlanned{ID: volPath, TargetLSN: plan.CatchUpTarget})
if rm.hasPendingExecution(volPath) {
rm.cancelPendingExecution(volPath, "start_catchup_not_emitted")
return
}
case engine.OutcomeNeedsRebuild:
reason := "needs_rebuild"
if plan.Proof != nil && plan.Proof.Reason != "" {
reason = plan.Proof.Reason
}
bs.applyCoreEvent(engine.NeedsRebuildObserved{ID: volPath, Reason: reason})
return
}
if ctx.Err() != nil {
driver.CancelPlan(plan, "context_cancelled")
return
}
exec := engine.NewCatchUpExecutor(driver, plan)
exec.IO = executor
if execErr := exec.Execute(nil, 0); execErr != nil {
if ctx.Err() != nil {
glog.V(1).Infof("recovery: catch-up cancelled for %s: %v", replicaID, execErr)
} else {
glog.Warningf("recovery: catch-up execution failed for %s: %v", replicaID, execErr)
}
return
}
glog.V(0).Infof("recovery: catch-up completed for %s", replicaID)
}
func (rm *RecoveryManager) runRebuild(ctx context.Context, replicaID, rebuildAddr string) {
@@ -306,25 +340,140 @@ func (rm *RecoveryManager) runRebuild(ctx context.Context, replicaID, rebuildAdd
glog.Warningf("recovery: rebuild plan failed for %s: %v", replicaID, err)
return
}
if ctx.Err() != nil {
driver.CancelPlan(plan, "context_cancelled")
return
}
exec := engine.NewRebuildExecutor(driver, plan)
exec.IO = executor
if execErr := exec.Execute(); execErr != nil {
if ctx.Err() != nil {
glog.V(1).Infof("recovery: rebuild cancelled for %s: %v", replicaID, execErr)
} else {
glog.Warningf("recovery: rebuild execution failed for %s: %v", replicaID, execErr)
if bs.v2Core == nil {
if err := rm.executeRebuildPlan(volPath, replicaID, driver, plan, executor); err != nil {
if ctx.Err() != nil {
glog.V(1).Infof("recovery: rebuild cancelled for %s: %v", replicaID, err)
} else {
glog.Warningf("recovery: rebuild execution failed for %s: %v", replicaID, err)
}
}
return
}
rm.storePendingExecution(volPath, &pendingRecoveryExecution{
volumeID: volPath,
replicaID: replicaID,
driver: driver,
plan: plan,
io: executor,
})
bs.applyCoreEvent(engine.RebuildStarted{ID: volPath, TargetLSN: plan.RebuildTargetLSN})
if rm.hasPendingExecution(volPath) {
rm.cancelPendingExecution(volPath, "start_rebuild_not_emitted")
}
}
func (rm *RecoveryManager) storePendingExecution(volumeID string, pending *pendingRecoveryExecution) {
rm.mu.Lock()
defer rm.mu.Unlock()
if rm.pending == nil {
rm.pending = make(map[string]*pendingRecoveryExecution)
}
rm.pending[volumeID] = pending
}
func (rm *RecoveryManager) takePendingExecution(volumeID string) (*pendingRecoveryExecution, bool) {
rm.mu.Lock()
defer rm.mu.Unlock()
pending, ok := rm.pending[volumeID]
if ok {
delete(rm.pending, volumeID)
}
return pending, ok
}
func (rm *RecoveryManager) hasPendingExecution(volumeID string) bool {
rm.mu.Lock()
defer rm.mu.Unlock()
_, ok := rm.pending[volumeID]
return ok
}
func (rm *RecoveryManager) cancelPendingExecution(volumeID, reason string) {
pending, ok := rm.takePendingExecution(volumeID)
if !ok || pending == nil || pending.driver == nil || pending.plan == nil {
return
}
pending.driver.CancelPlan(pending.plan, reason)
}
func (rm *RecoveryManager) ExecutePendingCatchUp(volumeID string, targetLSN uint64) error {
pending, ok := rm.takePendingExecution(volumeID)
if !ok || pending == nil || pending.plan == nil || pending.driver == nil {
return nil
}
if pending.plan.CatchUpTarget != targetLSN {
pending.driver.CancelPlan(pending.plan, "start_catchup_target_mismatch")
return nil
}
return rm.executeCatchUpPlan(volumeID, pending.replicaID, pending.driver, pending.plan, pending.io)
}
func (rm *RecoveryManager) ExecutePendingRebuild(volumeID string, targetLSN uint64) error {
pending, ok := rm.takePendingExecution(volumeID)
if !ok || pending == nil || pending.plan == nil || pending.driver == nil {
return nil
}
if pending.plan.RebuildTargetLSN != targetLSN {
pending.driver.CancelPlan(pending.plan, "start_rebuild_target_mismatch")
return nil
}
return rm.executeRebuildPlan(volumeID, pending.replicaID, pending.driver, pending.plan, pending.io)
}
func (rm *RecoveryManager) executeCatchUpPlan(volumeID, replicaID string, driver *engine.RecoveryDriver, plan *engine.RecoveryPlan, io *v2bridge.Executor) error {
exec := engine.NewCatchUpExecutor(driver, plan)
exec.IO = io
if err := exec.Execute(nil, 0); err != nil {
return err
}
glog.V(0).Infof("recovery: catch-up completed for %s", replicaID)
if rm.bs != nil && rm.bs.v2Core != nil {
achievedLSN := plan.CatchUpTarget
if achievedLSN == 0 {
achievedLSN = plan.CatchUpStartLSN
}
rm.bs.applyCoreEvent(engine.CatchUpCompleted{ID: volumeID, AchievedLSN: achievedLSN})
}
return nil
}
func (rm *RecoveryManager) executeRebuildPlan(volumeID, replicaID string, driver *engine.RecoveryDriver, plan *engine.RecoveryPlan, io *v2bridge.Executor) error {
exec := engine.NewRebuildExecutor(driver, plan)
exec.IO = io
if err := exec.Execute(); err != nil {
return err
}
glog.V(0).Infof("recovery: rebuild completed for %s", replicaID)
if rm.bs == nil || rm.bs.v2Core == nil {
return nil
}
var snap blockvol.V2StatusSnapshot
if err := rm.bs.blockStore.WithVolume(volumeID, func(vol *blockvol.BlockVol) error {
snap = vol.StatusSnapshot()
return nil
}); err != nil {
glog.Warningf("recovery: cannot read status snapshot for %s after rebuild: %v", volumeID, err)
}
flushedLSN := snap.CommittedLSN
if flushedLSN == 0 {
flushedLSN = plan.RebuildTargetLSN
}
checkpointLSN := snap.CheckpointLSN
if checkpointLSN == 0 {
checkpointLSN = plan.RebuildTargetLSN
}
achievedLSN := flushedLSN
if checkpointLSN > achievedLSN {
achievedLSN = checkpointLSN
}
rm.bs.applyCoreEvent(engine.RebuildCommitted{
ID: volumeID,
AchievedLSN: achievedLSN,
FlushedLSN: flushedLSN,
CheckpointLSN: checkpointLSN,
})
return nil
}
func (rm *RecoveryManager) deriveRebuildAddr(replicaID string, assignments []blockvol.BlockVolumeAssignment) string {
+149
View File
@@ -1,6 +1,7 @@
package weed_server
import (
"context"
"path/filepath"
"testing"
"time"
@@ -62,6 +63,45 @@ func createTestBlockServiceWithVol(t *testing.T) (*BlockService, string) {
return bs, volPath
}
func createTestBlockServiceWithVolCoreNoRecovery(t *testing.T) (*BlockService, string) {
t.Helper()
dir := t.TempDir()
volPath := filepath.Join(dir, "vol1.blk")
vol, err := blockvol.CreateBlockVol(volPath, blockvol.CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 256 * 1024,
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
vol.Close()
store := storage.NewBlockVolumeStore()
if _, err := store.AddBlockVolume(volPath, ""); err != nil {
t.Fatalf("AddBlockVolume: %v", err)
}
bs := &BlockService{
blockStore: store,
blockDir: dir,
listenAddr: "127.0.0.1:3260",
localServerID: "test-server-1",
v2Bridge: v2bridge.NewControlBridge(),
v2Orchestrator: engine.NewRecoveryOrchestrator(),
v2Core: engine.NewCoreEngine(),
coreProj: make(map[string]engine.PublicationProjection),
replStates: make(map[string]*volReplState),
}
t.Cleanup(func() {
store.Close()
})
return bs, volPath
}
// --- Live-path with real vol: reaches planning ---
func TestP4_LivePath_RealVol_ReachesPlan(t *testing.T) {
@@ -125,6 +165,115 @@ func TestP4_LivePath_RealVol_ReachesPlan(t *testing.T) {
t.Log("P4 live-path: ProcessAssignments → plan_catchup → exec_catchup_started → exec_completed → in_sync")
}
func TestP16B_RunCatchUp_UpdatesCoreProjectionFromLiveRecovery(t *testing.T) {
bs, volPath := createTestBlockServiceWithVolCoreNoRecovery(t)
if err := bs.blockStore.WithVolume(volPath, func(vol *blockvol.BlockVol) error {
for i := 0; i < 5; i++ {
if err := vol.WriteLBA(uint64(i), make([]byte, 4096)); err != nil {
return err
}
}
return nil
}); err != nil {
t.Fatalf("write: %v", err)
}
bs.ProcessAssignments([]blockvol.BlockVolumeAssignment{
{
Path: volPath,
Epoch: 1,
Role: uint32(blockvol.RolePrimary),
ReplicaServerID: "vs2",
ReplicaDataAddr: "10.0.0.2:9333",
ReplicaCtrlAddr: "10.0.0.2:9334",
},
})
replicaID := volPath + "/vs2"
sender := bs.v2Orchestrator.Registry.Sender(replicaID)
if sender == nil || !sender.HasActiveSession() {
t.Fatal("expected active sender session before catch-up")
}
rm := NewRecoveryManager(bs)
bs.v2Recovery = rm
rm.runCatchUp(context.Background(), replicaID, "")
proj, ok := bs.CoreProjection(volPath)
if !ok {
t.Fatal("expected cached core projection after live catch-up")
}
if proj.Boundary.TargetLSN == 0 {
t.Fatalf("target_lsn=%d", proj.Boundary.TargetLSN)
}
if proj.Boundary.AchievedLSN == 0 {
t.Fatalf("achieved_lsn=%d", proj.Boundary.AchievedLSN)
}
if proj.Boundary.DurableLSN == 0 {
t.Fatalf("durable_lsn=%d", proj.Boundary.DurableLSN)
}
if proj.Recovery.Phase != engine.RecoveryIdle {
t.Fatalf("recovery_phase=%s", proj.Recovery.Phase)
}
if got := bs.ExecutedCoreCommands(volPath); len(got) == 0 || got[len(got)-1] != "start_catchup" {
t.Fatalf("expected start_catchup execution, got %v", got)
}
}
func TestP16B_RunCatchUp_EscalatesNeedsRebuildIntoCoreProjection(t *testing.T) {
bs, volPath := createTestBlockServiceWithVolCoreNoRecovery(t)
if err := bs.blockStore.WithVolume(volPath, func(vol *blockvol.BlockVol) error {
for i := 0; i < 5; i++ {
if err := vol.WriteLBA(uint64(i), make([]byte, 4096)); err != nil {
return err
}
}
return vol.ForceFlush()
}); err != nil {
t.Fatalf("write+flush: %v", err)
}
bs.ProcessAssignments([]blockvol.BlockVolumeAssignment{
{
Path: volPath,
Epoch: 1,
Role: uint32(blockvol.RolePrimary),
ReplicaServerID: "vs2",
ReplicaDataAddr: "10.0.0.2:9333",
ReplicaCtrlAddr: "10.0.0.2:9334",
},
})
replicaID := volPath + "/vs2"
sender := bs.v2Orchestrator.Registry.Sender(replicaID)
if sender == nil || !sender.HasActiveSession() {
t.Fatal("expected active sender session before needs_rebuild planning")
}
rm := NewRecoveryManager(bs)
bs.v2Recovery = rm
rm.runCatchUp(context.Background(), replicaID, "")
proj, ok := bs.CoreProjection(volPath)
if !ok {
t.Fatal("expected cached core projection after needs_rebuild escalation")
}
if proj.Mode.Name != engine.ModeNeedsRebuild {
t.Fatalf("mode=%s", proj.Mode.Name)
}
if proj.Recovery.Phase != engine.RecoveryNeedsRebuild {
t.Fatalf("recovery_phase=%s", proj.Recovery.Phase)
}
if proj.Publication.Reason == "" {
t.Fatal("expected needs_rebuild reason")
}
if got := bs.ExecutedCoreCommands(volPath); len(got) != 3 {
t.Fatalf("needs_rebuild path should not execute start_catchup, got %v", got)
}
}
// --- Serialized replacement: old drained before new starts ---
func TestP4_SerializedReplacement_DrainsBeforeStart(t *testing.T) {
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
)
@@ -217,6 +218,126 @@ func TestBlockStatusHandler_IncludesHealthCounts(t *testing.T) {
}
}
func TestBlockStatusHandler_ReflectsCoreInfluencedConsumeCounts(t *testing.T) {
ms := qaPlanMaster(t)
// Ready path: replica heartbeat publishes receiver addrs from the
// core-influenced ready path, then registry consumes it into a healthy volume.
readyBS := newTestBlockServiceDirect(t)
readyPath := createTestVolDirect(t, readyBS, "status-ready")
errs := readyBS.ApplyAssignments([]blockvol.BlockVolumeAssignment{{
Path: readyPath,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
}})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("ready apply errs=%v", errs)
}
readyBS.replMu.Lock()
readyState := readyBS.replStates[readyPath]
if readyState == nil {
readyBS.replMu.Unlock()
t.Fatal("missing ready repl state")
}
readyState.publishHealthy = false
readyBS.replMu.Unlock()
readyHB := findHeartbeatMsg(readyBS.CollectBlockVolumeHeartbeat(), readyPath)
if readyHB == nil {
t.Fatal("ready heartbeat missing")
}
if err := ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "status-ready",
VolumeServer: "vs1:9333",
Path: "/blocks/status-ready-primary.blk",
Epoch: 1,
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "vs2:9333",
Path: readyPath,
}},
}); err != nil {
t.Fatalf("register ready: %v", err)
}
ms.blockRegistry.mu.Lock()
ms.blockRegistry.addToServer("vs2:9333", "status-ready")
ms.blockRegistry.mu.Unlock()
ms.blockRegistry.UpdateFullHeartbeat("vs2:9333", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*readyHB}), "")
// Degraded path: primary heartbeat publishes degraded bit from the
// core-influenced degraded path, then registry consumes it into a degraded volume.
degradedBS := newTestBlockServiceDirect(t)
degradedPath := createTestVolDirect(t, degradedBS, "status-degraded")
errs = degradedBS.ApplyAssignments([]blockvol.BlockVolumeAssignment{{
Path: degradedPath,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs1:9333",
ReplicaDataAddr: "10.0.0.3:4260",
ReplicaCtrlAddr: "10.0.0.3:4261",
}})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("degraded apply errs=%v", errs)
}
degradedBS.applyCoreEvent(engine.BarrierRejected{ID: degradedPath, Reason: "barrier_timeout"})
degradedHB := findHeartbeatMsg(degradedBS.CollectBlockVolumeHeartbeat(), degradedPath)
if degradedHB == nil {
t.Fatal("degraded heartbeat missing")
}
if err := ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "status-degraded",
VolumeServer: "vs3:9333",
Path: degradedPath,
Epoch: 1,
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "vs1:9333",
Path: "/blocks/status-degraded-replica.blk",
Ready: true,
}},
}); err != nil {
t.Fatalf("register degraded: %v", err)
}
ms.blockRegistry.UpdateFullHeartbeat("vs3:9333", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*degradedHB}), "")
req := httptest.NewRequest(http.MethodGet, "/block/status", nil)
w := httptest.NewRecorder()
ms.blockStatusHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp blockapi.BlockStatusResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VolumeCount != 2 {
t.Fatalf("expected volume_count=2, got %d", resp.VolumeCount)
}
if resp.HealthyCount != 1 {
t.Fatalf("expected healthy_count=1, got %d", resp.HealthyCount)
}
if resp.DegradedCount != 1 {
t.Fatalf("expected degraded_count=1, got %d", resp.DegradedCount)
}
if resp.RebuildingCount != 0 {
t.Fatalf("expected rebuilding_count=0, got %d", resp.RebuildingCount)
}
if resp.UnsafeCount != 0 {
t.Fatalf("expected unsafe_count=0, got %d", resp.UnsafeCount)
}
}
// --- VolumeInfo includes health_state ---
func TestEntryToVolumeInfo_IncludesHealthState(t *testing.T) {
@@ -234,6 +355,129 @@ func TestEntryToVolumeInfo_IncludesHealthState(t *testing.T) {
}
}
func TestEntryToVolumeInfo_ReflectsCoreInfluencedReadyConsume(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-info-ready")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.replMu.Lock()
state := bs.replStates[path]
if state == nil {
bs.replMu.Unlock()
t.Fatal("missing repl state")
}
state.publishHealthy = false
bs.replMu.Unlock()
hb := findHeartbeatMsg(bs.CollectBlockVolumeHeartbeat(), path)
if hb == nil {
t.Fatal("heartbeat volume missing")
}
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("primary-server:8080")
if err := r.Register(&BlockVolumeEntry{
Name: "vol-info-ready",
VolumeServer: "primary-server:8080",
Path: "/blocks/vol-info-ready-primary.blk",
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "replica-server:8080",
Path: path,
}},
}); err != nil {
t.Fatalf("register: %v", err)
}
r.UpdateFullHeartbeat("replica-server:8080", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*hb}), "")
entry, _ := r.Lookup("vol-info-ready")
info := entryToVolumeInfo(&entry, true)
if !info.ReplicaReady {
t.Fatalf("expected outward ReplicaReady=true, info=%+v", info)
}
if info.ReplicaDegraded {
t.Fatalf("expected outward ReplicaDegraded=false, info=%+v", info)
}
if info.VolumeMode != "publish_healthy" {
t.Fatalf("expected outward VolumeMode=publish_healthy, got %q", info.VolumeMode)
}
if info.HealthState != HealthStateHealthy {
t.Fatalf("expected outward HealthState=%q, got %q", HealthStateHealthy, info.HealthState)
}
}
func TestEntryToVolumeInfo_ReflectsCoreInfluencedDegradedConsume(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-info-degraded")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "replica-server:8080",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.applyCoreEvent(engine.BarrierRejected{ID: path, Reason: "barrier_timeout"})
hb := findHeartbeatMsg(bs.CollectBlockVolumeHeartbeat(), path)
if hb == nil {
t.Fatal("heartbeat volume missing")
}
r := NewBlockVolumeRegistry()
r.MarkBlockCapable("primary-server:8080")
if err := r.Register(&BlockVolumeEntry{
Name: "vol-info-degraded",
VolumeServer: "primary-server:8080",
Path: path,
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "replica-server:8080",
Path: "/blocks/vol-info-degraded-replica.blk",
Ready: true,
}},
}); err != nil {
t.Fatalf("register: %v", err)
}
r.UpdateFullHeartbeat("primary-server:8080", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*hb}), "")
entry, _ := r.Lookup("vol-info-degraded")
info := entryToVolumeInfo(&entry, true)
if !info.ReplicaDegraded {
t.Fatalf("expected outward ReplicaDegraded=true, info=%+v", info)
}
if info.VolumeMode != "degraded" {
t.Fatalf("expected outward VolumeMode=degraded, got %q", info.VolumeMode)
}
if info.HealthState != HealthStateDegraded {
t.Fatalf("expected outward HealthState=%q, got %q", HealthStateDegraded, info.HealthState)
}
}
func TestEntryToVolumeInfo_PrimaryDead_Unsafe(t *testing.T) {
entry := &BlockVolumeEntry{
Name: "dead-primary-vol",
+127 -111
View File
@@ -71,23 +71,23 @@ type BlockVolumeEntry struct {
RebuildListenAddr string // rebuild server listen addr on primary
// CP8-2: Multi-replica support.
ReplicaFactor int // 2 or 3 (default 2)
Replicas []ReplicaInfo // one per replica (RF-1 entries)
HealthScore float64 // primary health score from heartbeat
ReplicaReady bool // all configured replicas are ready for publication
ReplicaDegraded bool // aggregate: transport degraded OR not ready
TransportDegraded bool // primary reports degraded replicas
ReplicaFactor int // 2 or 3 (default 2)
Replicas []ReplicaInfo // one per replica (RF-1 entries)
HealthScore float64 // primary health score from heartbeat
ReplicaReady bool // all configured replicas are ready for publication
ReplicaDegraded bool // aggregate: transport degraded OR not ready
TransportDegraded bool // primary reports degraded replicas
// CP13-9: Normalized volume mode for external surfaces.
// Computed by recomputeReplicaState from the current entry state.
VolumeMode string // "allocated_only", "bootstrap_pending", "publish_healthy", "degraded", "needs_rebuild"
WALHeadLSN uint64 // primary WAL head LSN from heartbeat
VolumeMode string // "allocated_only", "bootstrap_pending", "publish_healthy", "degraded", "needs_rebuild"
WALHeadLSN uint64 // primary WAL head LSN from heartbeat
// CP8-3-1: Durability mode.
DurabilityMode string // "best_effort", "sync_all", "sync_quorum"
DurabilityMode string // "best_effort", "sync_all", "sync_quorum"
// CP11B-1: Provisioning preset (control-plane metadata only).
Preset string // "database", "general", "throughput", or ""
Preset string // "database", "general", "throughput", or ""
// Lease tracking for failover (CP6-3 F2).
LastLeaseGrant time.Time
@@ -95,7 +95,7 @@ type BlockVolumeEntry struct {
// CP11A-2: Coordinated expand tracking.
ExpandInProgress bool
ExpandFailed bool // true = primary committed but replica(s) failed; size suppressed
ExpandFailed bool // true = primary committed but replica(s) failed; size suppressed
PendingExpandSize uint64
ExpandEpoch uint64
@@ -256,7 +256,6 @@ type PlacementCandidateInfo struct {
NvmeCapable bool
}
// NewBlockVolumeRegistry creates an empty registry.
func NewBlockVolumeRegistry() *BlockVolumeRegistry {
return &BlockVolumeRegistry{
@@ -452,7 +451,7 @@ type ReplicaAddrChange struct {
// HeartbeatResult holds the side effects from UpdateFullHeartbeat that the
// caller (heartbeat handler) must process.
type HeartbeatResult struct {
AddrChanges []ReplicaAddrChange
AddrChanges []ReplicaAddrChange
PrimaryRefreshNeeded []BlockVolumeEntry // CP13-8A: entries needing primary assignment refresh
}
@@ -551,88 +550,9 @@ func (r *BlockVolumeRegistry) UpdateFullHeartbeat(server string, infos []*master
isReplica := existing.ReplicaByServer(server) != nil
if isPrimary {
// Primary heartbeat: update primary fields.
// CP11A-2: skip size update during coordinated expand.
if !existing.ExpandInProgress {
existing.SizeBytes = info.VolumeSize
}
existing.Epoch = info.Epoch
existing.Role = info.Role
existing.Status = StatusActive
existing.LastLeaseGrant = time.Now()
existing.HealthScore = info.HealthScore
existing.TransportDegraded = info.ReplicaDegraded
existing.WALHeadLSN = info.WalHeadLsn
// F3: only update DurabilityMode when non-empty (prevents older VS from clearing strict mode).
if info.DurabilityMode != "" {
existing.DurabilityMode = info.DurabilityMode
}
// F5: update replica addresses from heartbeat info.
if info.ReplicaDataAddr != "" {
existing.ReplicaDataAddr = info.ReplicaDataAddr
}
if info.ReplicaCtrlAddr != "" {
existing.ReplicaCtrlAddr = info.ReplicaCtrlAddr
}
// NVMe publication: update NVMe fields from heartbeat.
// Required for master restart reconstruction and NVMe enable/disable.
existing.NvmeAddr = info.NvmeAddr
existing.NQN = info.Nqn
// Sync first replica's data addrs to Replicas[].
if info.ReplicaDataAddr != "" && len(existing.Replicas) > 0 {
existing.Replicas[0].DataAddr = info.ReplicaDataAddr
existing.Replicas[0].CtrlAddr = info.ReplicaCtrlAddr
}
existing.recomputeReplicaState()
r.applyPrimaryHeartbeatObservation(existing, info)
} else if isReplica {
// Replica heartbeat: update ReplicaInfo fields.
for i := range existing.Replicas {
if existing.Replicas[i].Server == server {
existing.Replicas[i].Path = info.Path
existing.Replicas[i].WALHeadLSN = info.WalHeadLsn
existing.Replicas[i].HealthScore = info.HealthScore
existing.Replicas[i].LastHeartbeat = time.Now()
// Keep role as RoleReplica — the VS may report a stale
// primary role if it hasn't received its demotion assignment yet.
// The registry's decision (lower epoch = replica) is authoritative.
existing.Replicas[i].Role = blockvol.RoleToWire(blockvol.RoleReplica)
existing.Replicas[i].NvmeAddr = info.NvmeAddr
existing.Replicas[i].NQN = info.Nqn
existing.Replicas[i].Ready = info.ReplicaDataAddr != "" && info.ReplicaCtrlAddr != ""
if existing.WALHeadLSN > info.WalHeadLsn {
existing.Replicas[i].WALLag = existing.WALHeadLSN - info.WalHeadLsn
} else {
existing.Replicas[i].WALLag = 0
}
// CP13-8: detect address change on replica restart.
// If either the data or control address changed, the primary's
// shipper has a stale endpoint. Queue a Primary refresh.
if info.ReplicaDataAddr != "" || info.ReplicaCtrlAddr != "" {
oldData := existing.Replicas[i].DataAddr
oldCtrl := existing.Replicas[i].CtrlAddr
dataChanged := info.ReplicaDataAddr != "" && oldData != "" && oldData != info.ReplicaDataAddr
ctrlChanged := info.ReplicaCtrlAddr != "" && oldCtrl != "" && oldCtrl != info.ReplicaCtrlAddr
if dataChanged || ctrlChanged {
result.AddrChanges = append(result.AddrChanges, ReplicaAddrChange{
VolumeName: existingName,
PrimaryServer: existing.VolumeServer,
OldDataAddr: oldData,
OldCtrlAddr: oldCtrl,
NewDataAddr: info.ReplicaDataAddr,
NewCtrlAddr: info.ReplicaCtrlAddr,
})
}
if info.ReplicaDataAddr != "" {
existing.Replicas[i].DataAddr = info.ReplicaDataAddr
}
if info.ReplicaCtrlAddr != "" {
existing.Replicas[i].CtrlAddr = info.ReplicaCtrlAddr
}
}
break
}
}
existing.recomputeReplicaState()
r.applyReplicaHeartbeatObservation(existing, server, existingName, info, &result)
} else {
// Server reports a volume that exists but has no record of this server.
// This happens after master restart. Use epoch-based reconciliation
@@ -657,19 +577,19 @@ func (r *BlockVolumeRegistry) UpdateFullHeartbeat(server string, infos []*master
existing, dup := r.volumes[name]
if !dup {
entry := &BlockVolumeEntry{
Name: name,
VolumeServer: server,
Path: info.Path,
SizeBytes: info.VolumeSize,
Epoch: info.Epoch,
Role: info.Role,
Status: StatusActive,
LastLeaseGrant: time.Now(),
LeaseTTL: 30 * time.Second,
HealthScore: info.HealthScore,
Name: name,
VolumeServer: server,
Path: info.Path,
SizeBytes: info.VolumeSize,
Epoch: info.Epoch,
Role: info.Role,
Status: StatusActive,
LastLeaseGrant: time.Now(),
LeaseTTL: 30 * time.Second,
HealthScore: info.HealthScore,
TransportDegraded: info.ReplicaDegraded,
WALHeadLSN: info.WalHeadLsn,
DurabilityMode: info.DurabilityMode,
WALHeadLSN: info.WalHeadLsn,
DurabilityMode: info.DurabilityMode,
}
if info.ReplicaDataAddr != "" {
entry.ReplicaDataAddr = info.ReplicaDataAddr
@@ -700,6 +620,103 @@ func (r *BlockVolumeRegistry) UpdateFullHeartbeat(server string, infos []*master
return result
}
// applyPrimaryHeartbeatObservation consumes one primary-side heartbeat into the
// registry entry. Caller must hold r.mu.
func (r *BlockVolumeRegistry) applyPrimaryHeartbeatObservation(existing *BlockVolumeEntry, info *master_pb.BlockVolumeInfoMessage) {
// CP11A-2: skip size update during coordinated expand.
if !existing.ExpandInProgress {
existing.SizeBytes = info.VolumeSize
}
existing.Epoch = info.Epoch
existing.Role = info.Role
existing.Status = StatusActive
existing.LastLeaseGrant = time.Now()
existing.HealthScore = info.HealthScore
existing.TransportDegraded = info.ReplicaDegraded
existing.WALHeadLSN = info.WalHeadLsn
// F3: only update DurabilityMode when non-empty (prevents older VS from clearing strict mode).
if info.DurabilityMode != "" {
existing.DurabilityMode = info.DurabilityMode
}
// F5: update replica addresses from heartbeat info.
if info.ReplicaDataAddr != "" {
existing.ReplicaDataAddr = info.ReplicaDataAddr
}
if info.ReplicaCtrlAddr != "" {
existing.ReplicaCtrlAddr = info.ReplicaCtrlAddr
}
// NVMe publication: update NVMe fields from heartbeat.
// Required for master restart reconstruction and NVMe enable/disable.
existing.NvmeAddr = info.NvmeAddr
existing.NQN = info.Nqn
// Sync first replica's data addrs to Replicas[].
if info.ReplicaDataAddr != "" && len(existing.Replicas) > 0 {
existing.Replicas[0].DataAddr = info.ReplicaDataAddr
existing.Replicas[0].CtrlAddr = info.ReplicaCtrlAddr
}
existing.recomputeReplicaState()
}
// applyReplicaHeartbeatObservation consumes one replica-side heartbeat into the
// registry entry. Caller must hold r.mu.
func (r *BlockVolumeRegistry) applyReplicaHeartbeatObservation(existing *BlockVolumeEntry, server, existingName string, info *master_pb.BlockVolumeInfoMessage, result *HeartbeatResult) {
for i := range existing.Replicas {
if existing.Replicas[i].Server != server {
continue
}
existing.Replicas[i].Path = info.Path
existing.Replicas[i].WALHeadLSN = info.WalHeadLsn
existing.Replicas[i].HealthScore = info.HealthScore
existing.Replicas[i].LastHeartbeat = time.Now()
// Keep role as RoleReplica — the VS may report a stale
// primary role if it hasn't received its demotion assignment yet.
// The registry's decision (lower epoch = replica) is authoritative.
existing.Replicas[i].Role = blockvol.RoleToWire(blockvol.RoleReplica)
existing.Replicas[i].NvmeAddr = info.NvmeAddr
existing.Replicas[i].NQN = info.Nqn
existing.Replicas[i].Ready = replicaReadyObservedFromHeartbeat(info)
if existing.WALHeadLSN > info.WalHeadLsn {
existing.Replicas[i].WALLag = existing.WALHeadLSN - info.WalHeadLsn
} else {
existing.Replicas[i].WALLag = 0
}
// CP13-8: detect address change on replica restart.
// If either the data or control address changed, the primary's
// shipper has a stale endpoint. Queue a Primary refresh.
if info.ReplicaDataAddr != "" || info.ReplicaCtrlAddr != "" {
oldData := existing.Replicas[i].DataAddr
oldCtrl := existing.Replicas[i].CtrlAddr
dataChanged := info.ReplicaDataAddr != "" && oldData != "" && oldData != info.ReplicaDataAddr
ctrlChanged := info.ReplicaCtrlAddr != "" && oldCtrl != "" && oldCtrl != info.ReplicaCtrlAddr
if dataChanged || ctrlChanged {
result.AddrChanges = append(result.AddrChanges, ReplicaAddrChange{
VolumeName: existingName,
PrimaryServer: existing.VolumeServer,
OldDataAddr: oldData,
OldCtrlAddr: oldCtrl,
NewDataAddr: info.ReplicaDataAddr,
NewCtrlAddr: info.ReplicaCtrlAddr,
})
}
if info.ReplicaDataAddr != "" {
existing.Replicas[i].DataAddr = info.ReplicaDataAddr
}
if info.ReplicaCtrlAddr != "" {
existing.Replicas[i].CtrlAddr = info.ReplicaCtrlAddr
}
}
break
}
existing.recomputeReplicaState()
}
func replicaReadyObservedFromHeartbeat(info *master_pb.BlockVolumeInfoMessage) bool {
if info == nil {
return false
}
return info.ReplicaDataAddr != "" && info.ReplicaCtrlAddr != ""
}
// reconcileOnRestart handles the case where a second server reports a volume
// name that already exists in the registry during master restart reconstruction.
// Uses epoch-based tie-breaking to determine who is the real primary.
@@ -1244,11 +1261,11 @@ type PromotionRejection struct {
// Used by auto-promotion, manual promote API, preflight status, and logging.
type PromotionPreflightResult struct {
VolumeName string
Promotable bool // true if a candidate was found
Candidate *ReplicaInfo // best candidate (nil if !Promotable)
CandidateIdx int // index in Replicas[] (-1 if !Promotable)
Promotable bool // true if a candidate was found
Candidate *ReplicaInfo // best candidate (nil if !Promotable)
CandidateIdx int // index in Replicas[] (-1 if !Promotable)
Rejections []PromotionRejection // why each non-candidate was rejected
Reason string // human-readable summary when !Promotable
Reason string // human-readable summary when !Promotable
}
// evaluatePromotionLocked evaluates promotion candidates for a volume.
@@ -1847,4 +1864,3 @@ func nameFromPath(path string) string {
}
return base
}
+133 -1
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
@@ -1391,7 +1392,7 @@ func TestRegistry_VolumesWithDeadPrimary_Basic(t *testing.T) {
r.Register(&BlockVolumeEntry{
Name: "vol1", VolumeServer: "vs1", Path: "/data/vol1.blk",
SizeBytes: 1 << 30, Epoch: 1, Role: blockvol.RoleToWire(blockvol.RolePrimary),
Status: StatusActive,
Status: StatusActive,
Replicas: []ReplicaInfo{{Server: "vs2", Path: "/data/vol1.blk"}},
})
@@ -2007,3 +2008,134 @@ func TestRegistry_ReplicaReadyRequiresReplicaHeartbeat(t *testing.T) {
t.Fatal("aggregate replica readiness should become true after replica heartbeat")
}
}
func TestRegistry_UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaDegraded(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-master-consume")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "replica-server:8080",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.applyCoreEvent(engine.BarrierRejected{ID: path, Reason: "barrier_timeout"})
hb := findHeartbeatMsg(bs.CollectBlockVolumeHeartbeat(), path)
if hb == nil {
t.Fatal("heartbeat volume missing")
}
if !hb.ReplicaDegraded {
t.Fatalf("expected core-influenced heartbeat degraded bit, hb=%+v", hb)
}
r := NewBlockVolumeRegistry()
if err := r.Register(&BlockVolumeEntry{
Name: "vol-master-consume",
VolumeServer: "primary-server:8080",
Path: path,
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "replica-server:8080",
Path: "/blocks/vol-master-consume.blk",
Ready: true,
}},
}); err != nil {
t.Fatalf("register: %v", err)
}
r.UpdateFullHeartbeat("primary-server:8080", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*hb}), "")
entry, _ := r.Lookup("vol-master-consume")
if !entry.TransportDegraded {
t.Fatalf("expected registry transport degraded from heartbeat, entry=%+v", entry)
}
if !entry.ReplicaDegraded {
t.Fatalf("expected registry aggregate degraded from heartbeat, entry=%+v", entry)
}
if entry.VolumeMode != "degraded" {
t.Fatalf("expected degraded volume mode after consume, got %q", entry.VolumeMode)
}
}
func TestRegistry_UpdateFullHeartbeat_ConsumesCoreInfluencedReplicaReady(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-master-ready")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.replMu.Lock()
state := bs.replStates[path]
if state == nil {
bs.replMu.Unlock()
t.Fatal("missing repl state")
}
state.publishHealthy = false
bs.replMu.Unlock()
hb := findHeartbeatMsg(bs.CollectBlockVolumeHeartbeat(), path)
if hb == nil {
t.Fatal("heartbeat volume missing")
}
if hb.ReplicaDataAddr == "" || hb.ReplicaCtrlAddr == "" {
t.Fatalf("expected core-influenced replica addresses on heartbeat, hb=%+v", hb)
}
if hb.ReplicaDegraded {
t.Fatalf("did not expect degraded heartbeat on ready path, hb=%+v", hb)
}
r := NewBlockVolumeRegistry()
if err := r.Register(&BlockVolumeEntry{
Name: "vol-master-ready",
VolumeServer: "primary-server:8080",
Path: "/blocks/vol-master-ready-primary.blk",
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "replica-server:8080",
Path: path,
}},
}); err != nil {
t.Fatalf("register: %v", err)
}
r.UpdateFullHeartbeat("replica-server:8080", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*hb}), "")
entry, _ := r.Lookup("vol-master-ready")
if !entry.Replicas[0].Ready {
t.Fatalf("expected replica ready from heartbeat addresses, entry=%+v", entry)
}
if !entry.ReplicaReady {
t.Fatalf("expected aggregate replica ready after consume, entry=%+v", entry)
}
if entry.ReplicaDegraded {
t.Fatalf("did not expect aggregate degraded after ready consume, entry=%+v", entry)
}
if entry.VolumeMode != "publish_healthy" {
t.Fatalf("expected publish_healthy after ready consume, got %q", entry.VolumeMode)
}
}
+6 -2
View File
@@ -250,7 +250,11 @@ func (ms *MasterServer) LookupBlockVolume(ctx context.Context, req *master_pb.Lo
return nil, fmt.Errorf("block volume %q not found", req.Name)
}
replicaServers := replicaServerList(&entry)
return lookupResponseFromEntry(&entry), nil
}
func lookupResponseFromEntry(entry *BlockVolumeEntry) *master_pb.LookupBlockVolumeResponse {
replicaServers := replicaServerList(entry)
rf := entry.ReplicaFactor
if rf == 0 {
rf = 2 // default for pre-CP8-2 entries
@@ -270,7 +274,7 @@ func (ms *MasterServer) LookupBlockVolume(ctx context.Context, req *master_pb.Lo
DurabilityMode: durModeStr,
NvmeAddr: entry.NvmeAddr,
Nqn: entry.NQN,
}, nil
}
}
// tryCreateOneReplica attempts to create one replica volume on a different server.
@@ -665,6 +665,47 @@ func TestMaster_LookupBlockVolume(t *testing.T) {
}
}
func TestLookupResponseFromEntry_PublicationMinimalSurface(t *testing.T) {
resp := lookupResponseFromEntry(&BlockVolumeEntry{
Name: "lookup-minimal",
VolumeServer: "vs1:9333",
ISCSIAddr: "10.0.0.1:3260",
IQN: "iqn.2024.test:lookup-minimal",
SizeBytes: 1 << 30,
ReplicaServer: "vs2:9333",
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{Server: "vs2:9333"}},
DurabilityMode: "sync_all",
NvmeAddr: "10.0.0.1:4420",
NQN: "nqn.2024-01.com.seaweedfs:vol.lookup-minimal",
ReplicaReady: true,
ReplicaDegraded: false,
VolumeMode: "publish_healthy",
})
if resp.VolumeServer != "vs1:9333" {
t.Fatalf("VolumeServer=%q", resp.VolumeServer)
}
if resp.IscsiAddr != "10.0.0.1:3260" {
t.Fatalf("IscsiAddr=%q", resp.IscsiAddr)
}
if resp.CapacityBytes != 1<<30 {
t.Fatalf("CapacityBytes=%d", resp.CapacityBytes)
}
if resp.ReplicaFactor != 2 {
t.Fatalf("ReplicaFactor=%d", resp.ReplicaFactor)
}
if len(resp.ReplicaServers) != 1 || resp.ReplicaServers[0] != "vs2:9333" {
t.Fatalf("ReplicaServers=%v", resp.ReplicaServers)
}
if resp.DurabilityMode != "sync_all" {
t.Fatalf("DurabilityMode=%q", resp.DurabilityMode)
}
if resp.NvmeAddr != "10.0.0.1:4420" || resp.Nqn != "nqn.2024-01.com.seaweedfs:vol.lookup-minimal" {
t.Fatalf("NVMe fields addr=%q nqn=%q", resp.NvmeAddr, resp.Nqn)
}
}
// ============================================================
// CP8-2 T9: Multi-Replica Create/Delete/Assign Tests
// ============================================================
+26 -6
View File
@@ -251,8 +251,12 @@ func (ms *MasterServer) blockVolumeExpandHandler(w http.ResponseWriter, r *http.
// blockStatusHandler handles GET /block/status — cluster summary with health counts.
func (ms *MasterServer) blockStatusHandler(w http.ResponseWriter, r *http.Request) {
writeJsonQuiet(w, r, http.StatusOK, ms.statusResponseFromRegistry())
}
func (ms *MasterServer) statusResponseFromRegistry() blockapi.BlockStatusResponse {
healthSummary := ms.blockRegistry.ComputeClusterHealthSummary()
status := blockapi.BlockStatusResponse{
return blockapi.BlockStatusResponse{
VolumeCount: len(ms.blockRegistry.ListAll()),
ServerCount: len(ms.blockRegistry.BlockCapableServers()),
PromotionLSNTolerance: ms.blockRegistry.PromotionLSNTolerance(),
@@ -267,7 +271,6 @@ func (ms *MasterServer) blockStatusHandler(w http.ResponseWriter, r *http.Reques
UnsafeCount: healthSummary.Unsafe,
NvmeCapableServers: ms.blockRegistry.NvmeCapableServerCount(),
}
writeJsonQuiet(w, r, http.StatusOK, status)
}
// blockVolumePreflightHandler handles GET /block/volume/{name}/preflight.
@@ -365,6 +368,22 @@ func (ms *MasterServer) blockVolumePromoteHandler(w http.ResponseWriter, r *http
// entryToVolumeInfo converts a BlockVolumeEntry to a blockapi.VolumeInfo.
// primaryAlive indicates whether the primary server is alive (in blockServers set).
type entryReplicaSurface struct {
ReplicaReady bool
ReplicaDegraded bool
VolumeMode string
HealthState string
}
func entryReplicaSurfaceInfo(e *BlockVolumeEntry, primaryAlive bool) entryReplicaSurface {
return entryReplicaSurface{
ReplicaReady: e.ReplicaReady,
ReplicaDegraded: e.ReplicaDegraded,
VolumeMode: e.VolumeMode,
HealthState: deriveHealthStateWithLiveness(e, primaryAlive),
}
}
func entryToVolumeInfo(e *BlockVolumeEntry, primaryAlive bool) blockapi.VolumeInfo {
status := "pending"
if e.Status == StatusActive {
@@ -378,6 +397,7 @@ func entryToVolumeInfo(e *BlockVolumeEntry, primaryAlive bool) blockapi.VolumeIn
if durMode == "" {
durMode = "best_effort"
}
surface := entryReplicaSurfaceInfo(e, primaryAlive)
info := blockapi.VolumeInfo{
Name: e.Name,
VolumeServer: e.VolumeServer,
@@ -394,15 +414,15 @@ func entryToVolumeInfo(e *BlockVolumeEntry, primaryAlive bool) blockapi.VolumeIn
ReplicaDataAddr: e.ReplicaDataAddr,
ReplicaCtrlAddr: e.ReplicaCtrlAddr,
ReplicaFactor: rf,
ReplicaReady: e.ReplicaReady,
ReplicaReady: surface.ReplicaReady,
HealthScore: e.HealthScore,
ReplicaDegraded: e.ReplicaDegraded,
ReplicaDegraded: surface.ReplicaDegraded,
DurabilityMode: durMode,
Preset: e.Preset,
NvmeAddr: e.NvmeAddr,
NQN: e.NQN,
HealthState: deriveHealthStateWithLiveness(e, primaryAlive),
VolumeMode: e.VolumeMode,
HealthState: surface.HealthState,
VolumeMode: surface.VolumeMode,
}
for _, ri := range e.Replicas {
info.Replicas = append(info.Replicas, blockapi.ReplicaDetail{
@@ -10,6 +10,8 @@ import (
"testing"
"github.com/gorilla/mux"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
)
@@ -154,6 +156,77 @@ func TestBlockVolumeLookupHandler(t *testing.T) {
}
}
func TestBlockVolumeLookupHandler_ReflectsCoreInfluencedReadyConsume(t *testing.T) {
ms, ts := blockTestServer(t)
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-handler-ready")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
}})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.replMu.Lock()
state := bs.replStates[path]
if state == nil {
bs.replMu.Unlock()
t.Fatal("missing repl state")
}
state.publishHealthy = false
bs.replMu.Unlock()
hb := findHeartbeatMsg(bs.CollectBlockVolumeHeartbeat(), path)
if hb == nil {
t.Fatal("heartbeat volume missing")
}
ms.blockRegistry.MarkBlockCapable("primary-server:8080")
if err := ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "vol-handler-ready",
VolumeServer: "primary-server:8080",
Path: "/blocks/vol-handler-ready-primary.blk",
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "replica-server:8080",
Path: path,
}},
}); err != nil {
t.Fatalf("register: %v", err)
}
ms.blockRegistry.UpdateFullHeartbeat("replica-server:8080", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*hb}), "")
resp, err := http.Get(ts.URL + "/block/volume/vol-handler-ready")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var info blockapi.VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
t.Fatal(err)
}
if !info.ReplicaReady {
t.Fatalf("expected outward ReplicaReady=true, info=%+v", info)
}
if info.ReplicaDegraded {
t.Fatalf("expected outward ReplicaDegraded=false, info=%+v", info)
}
if info.VolumeMode != "publish_healthy" {
t.Fatalf("expected outward VolumeMode=publish_healthy, got %q", info.VolumeMode)
}
}
func TestBlockVolumeDeleteHandler(t *testing.T) {
ms, ts := blockTestServer(t)
@@ -180,6 +253,73 @@ func TestBlockVolumeDeleteHandler(t *testing.T) {
}
}
func TestBlockVolumeListHandler_ReflectsCoreInfluencedDegradedConsume(t *testing.T) {
ms, ts := blockTestServer(t)
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-handler-degraded")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "replica-server:8080",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
}})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.applyCoreEvent(engine.BarrierRejected{ID: path, Reason: "barrier_timeout"})
hb := findHeartbeatMsg(bs.CollectBlockVolumeHeartbeat(), path)
if hb == nil {
t.Fatal("heartbeat volume missing")
}
ms.blockRegistry.MarkBlockCapable("primary-server:8080")
if err := ms.blockRegistry.Register(&BlockVolumeEntry{
Name: "vol-handler-degraded",
VolumeServer: "primary-server:8080",
Path: path,
Status: StatusActive,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
ReplicaFactor: 2,
Replicas: []ReplicaInfo{{
Server: "replica-server:8080",
Path: "/blocks/vol-handler-degraded-replica.blk",
Ready: true,
}},
}); err != nil {
t.Fatalf("register: %v", err)
}
ms.blockRegistry.UpdateFullHeartbeat("primary-server:8080", blockvol.InfoMessagesToProto([]blockvol.BlockVolumeInfoMessage{*hb}), "")
resp, err := http.Get(ts.URL + "/block/volumes")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var infos []blockapi.VolumeInfo
if err := json.NewDecoder(resp.Body).Decode(&infos); err != nil {
t.Fatal(err)
}
if len(infos) != 1 {
t.Fatalf("expected 1 volume, got %d", len(infos))
}
info := infos[0]
if !info.ReplicaDegraded {
t.Fatalf("expected outward ReplicaDegraded=true, info=%+v", info)
}
if info.VolumeMode != "degraded" {
t.Fatalf("expected outward VolumeMode=degraded, got %q", info.VolumeMode)
}
}
func TestBlockAssignHandler(t *testing.T) {
ms, ts := blockTestServer(t)
+1
View File
@@ -48,6 +48,7 @@ func createP2BlockService(t *testing.T) (*BlockService, string) {
localServerID: "vs1-node:18080",
v2Bridge: v2bridge.NewControlBridge(),
v2Orchestrator: engine.NewRecoveryOrchestrator(),
v2Core: engine.NewCoreEngine(),
replStates: make(map[string]*volReplState),
}
bs.v2Recovery = NewRecoveryManager(bs)
+1
View File
@@ -47,6 +47,7 @@ func createP3BlockService(t *testing.T) (*BlockService, string) {
localServerID: "vs1-node:18080",
v2Bridge: v2bridge.NewControlBridge(),
v2Orchestrator: engine.NewRecoveryOrchestrator(),
v2Core: engine.NewCoreEngine(),
replStates: make(map[string]*volReplState),
}
bs.v2Recovery = NewRecoveryManager(bs)
+2
View File
@@ -95,6 +95,7 @@ func newSoakSetup(t *testing.T) *soakSetup {
advertisedHost: "127.0.0.1",
v2Bridge: v2bridge.NewControlBridge(),
v2Orchestrator: engine.NewRecoveryOrchestrator(),
v2Core: engine.NewCoreEngine(),
replStates: make(map[string]*volReplState),
}
bs.v2Recovery = NewRecoveryManager(bs)
@@ -156,6 +157,7 @@ func TestCP13_2_BlockService_AdvertisedHost_NotOpaqueID(t *testing.T) {
advertisedHost: "10.0.0.42", // routable
v2Bridge: v2bridge.NewControlBridge(),
v2Orchestrator: engine.NewRecoveryOrchestrator(),
v2Core: engine.NewCoreEngine(),
replStates: make(map[string]*volReplState),
}
+395 -33
View File
@@ -9,9 +9,9 @@ import (
"strings"
"sync"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/storage"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/iscsi"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/nvme"
@@ -23,17 +23,22 @@ type volReplState struct {
replicaDataAddr string
replicaCtrlAddr string
// allReplicas stores the full replica set for multi-replica idempotence.
allReplicas []blockvol.ReplicaAddr
roleApplied bool
receiverReady bool
allReplicas []blockvol.ReplicaAddr
roleApplied bool
receiverReady bool
shipperConfigured bool
replicaEligible bool
publishHealthy bool
replicaEligible bool
publishHealthy bool
}
// BlockReadinessSnapshot names the assignment-to-publication closure at the
// BlockService boundary. These flags are owned by the service/adapter layer,
// not by blockvol's local storage mechanics.
//
// Important:
// PublishHealthy here is still an adapter-local publication bit used by current
// `weed/server` surfaces. It is NOT the semantic owner for Phase 14 core
// publication health; that owner is `engine.PublicationView`.
type BlockReadinessSnapshot struct {
RoleApplied bool
ReceiverReady bool
@@ -70,7 +75,12 @@ type BlockService struct {
// V2 engine bridge (Phase 08 P1).
v2Bridge *v2bridge.ControlBridge
v2Orchestrator *engine.RecoveryOrchestrator
v2Core *engine.CoreEngine
v2Recovery *RecoveryManager
coreProjMu sync.RWMutex
coreProj map[string]engine.PublicationProjection
coreExecMu sync.RWMutex
coreExec map[string][]string
// P3: last-applied assignment per volume path for idempotence.
lastAssignMu sync.RWMutex
@@ -92,6 +102,63 @@ func (bs *BlockService) V2Orchestrator() *engine.RecoveryOrchestrator {
return bs.v2Orchestrator
}
// V2Core returns the explicit Phase 14/15 core shell if wired.
func (bs *BlockService) V2Core() *engine.CoreEngine {
return bs.v2Core
}
// CoreProjection returns the latest adapter-cached projection emitted by the
// explicit V2 core on the narrow live path.
func (bs *BlockService) CoreProjection(path string) (engine.PublicationProjection, bool) {
bs.coreProjMu.RLock()
defer bs.coreProjMu.RUnlock()
if bs.coreProj == nil {
return engine.PublicationProjection{}, false
}
proj, ok := bs.coreProj[path]
return proj, ok
}
// ExecutedCoreCommands returns the bounded list of core commands executed on the
// current integrated path for one volume. Intended for focused runtime-ownership
// proofs in Phase 16.
func (bs *BlockService) ExecutedCoreCommands(path string) []string {
bs.coreExecMu.RLock()
defer bs.coreExecMu.RUnlock()
if bs.coreExec == nil {
return nil
}
cmds := bs.coreExec[path]
out := make([]string, len(cmds))
copy(out, cmds)
return out
}
// CoreProjectionMismatches reports fields that should already agree on the
// narrow Phase 15A path but do not. It intentionally excludes adapter-local
// `PublishHealthy`, which is not yet rebound to the core publication owner.
func (bs *BlockService) CoreProjectionMismatches(path string) []string {
proj, ok := bs.CoreProjection(path)
if !ok {
return []string{"missing_core_projection"}
}
readiness := bs.ReadinessSnapshot(path)
var mismatches []string
if readiness.RoleApplied != proj.Readiness.RoleApplied {
mismatches = append(mismatches, "role_applied")
}
if readiness.ReceiverReady != proj.Readiness.ReceiverReady {
mismatches = append(mismatches, "receiver_ready")
}
if readiness.ShipperConfigured != proj.Readiness.ShipperConfigured {
mismatches = append(mismatches, "shipper_configured")
}
if readiness.ShipperConnected != proj.Readiness.ShipperConnected {
mismatches = append(mismatches, "shipper_connected")
}
return mismatches
}
// SetServerID sets the stable server identity for V2 control semantics.
// This may be an opaque string (from -id flag) — not guaranteed routable.
func (bs *BlockService) SetServerID(id string) {
@@ -144,7 +211,9 @@ func StartBlockService(listenAddr, blockDir, iqnPrefix, portalAddr string, nvmeC
nvmeListenAddr: nvmeCfg.ListenAddr,
v2Bridge: v2bridge.NewControlBridge(),
v2Orchestrator: engine.NewRecoveryOrchestrator(),
v2Core: engine.NewCoreEngine(),
localServerID: listenAddr, // INTERIM: transport-shaped, see field doc
coreProj: make(map[string]engine.PublicationProjection),
}
bs.v2Recovery = NewRecoveryManager(bs)
@@ -430,37 +499,17 @@ func (bs *BlockService) ApplyAssignments(assignments []blockvol.BlockVolumeAssig
}
for i, a := range assignments {
role := blockvol.RoleFromWire(a.Role)
ttl := blockvol.LeaseTTLFromWire(a.LeaseTtlMs)
// 1. Apply role/epoch/lease.
if err := bs.blockStore.WithVolume(a.Path, func(vol *blockvol.BlockVol) error {
return vol.HandleAssignment(a.Epoch, role, ttl)
}); err != nil {
bs.recordAppliedAssignment(a)
if err := bs.applyCoreAssignmentEvent(a); err != nil {
errs[i] = err
glog.Warningf("block service: assignment %s epoch=%d role=%s: %v", a.Path, a.Epoch, role, err)
continue
}
bs.noteRoleApplied(a.Path, role)
// 2. Replication setup based on role + addresses.
switch role {
case blockvol.RolePrimary:
// CP8-2: ReplicaAddrs (multi-replica) takes precedence over scalar fields.
if len(a.ReplicaAddrs) > 0 {
if err := bs.setupPrimaryReplicationMulti(a.Path, a.ReplicaAddrs); err != nil {
errs[i] = err
}
} else if a.ReplicaDataAddr != "" && a.ReplicaCtrlAddr != "" {
if err := bs.setupPrimaryReplication(a.Path, a.ReplicaDataAddr, a.ReplicaCtrlAddr); err != nil {
errs[i] = err
}
}
case blockvol.RoleReplica:
if a.ReplicaDataAddr != "" && a.ReplicaCtrlAddr != "" {
if err := bs.setupReplicaReceiver(a.Path, a.ReplicaDataAddr, a.ReplicaCtrlAddr); err != nil {
errs[i] = err
}
}
case blockvol.RoleRebuilding:
if a.RebuildAddr != "" {
bs.startRebuild(a.Path, a.RebuildAddr, a.Epoch)
@@ -470,6 +519,263 @@ func (bs *BlockService) ApplyAssignments(assignments []blockvol.BlockVolumeAssig
return errs
}
func (bs *BlockService) applyCoreAssignmentEvent(a blockvol.BlockVolumeAssignment) error {
if bs == nil || bs.v2Core == nil {
return bs.applyRoleAssignment(a)
}
ev, ok := bs.coreAssignmentEvent(a)
if !ok {
return nil
}
result := bs.v2Core.ApplyEvent(ev)
return bs.applyCoreCommandsWithAssignment(result.Commands, &a)
}
func (bs *BlockService) applyCoreEvent(ev engine.Event) {
if bs == nil || bs.v2Core == nil {
return
}
result := bs.v2Core.ApplyEvent(ev)
bs.applyCoreCommands(result.Commands)
}
func (bs *BlockService) applyCoreCommands(cmds []engine.Command) {
_ = bs.applyCoreCommandsWithAssignment(cmds, nil)
}
func (bs *BlockService) applyCoreCommandsWithAssignment(cmds []engine.Command, assignment *blockvol.BlockVolumeAssignment) error {
for _, cmd := range cmds {
switch v := cmd.(type) {
case engine.ApplyRoleCommand:
if err := bs.executeApplyRoleCommand(v, assignment); err != nil {
return err
}
case engine.StartReceiverCommand:
if err := bs.executeStartReceiverCommand(v, assignment); err != nil {
return err
}
case engine.ConfigureShipperCommand:
if err := bs.executeConfigureShipperCommand(v); err != nil {
return err
}
case engine.InvalidateSessionCommand:
if err := bs.executeInvalidateSessionCommand(v); err != nil {
return err
}
case engine.StartCatchUpCommand:
if err := bs.executeStartCatchUpCommand(v); err != nil {
return err
}
case engine.StartRebuildCommand:
if err := bs.executeStartRebuildCommand(v); err != nil {
return err
}
case engine.PublishProjectionCommand:
proj := v.Projection
if latest, ok := bs.V2Core().Projection(v.VolumeID); ok {
proj = latest
}
bs.coreProjMu.Lock()
if bs.coreProj == nil {
bs.coreProj = make(map[string]engine.PublicationProjection)
}
bs.coreProj[v.VolumeID] = proj
bs.coreProjMu.Unlock()
}
}
return nil
}
func (bs *BlockService) executeApplyRoleCommand(cmd engine.ApplyRoleCommand, assignment *blockvol.BlockVolumeAssignment) error {
if assignment == nil {
return nil
}
if assignment.Path != cmd.VolumeID {
return fmt.Errorf("block service: core apply_role path mismatch %q != %q", assignment.Path, cmd.VolumeID)
}
if err := bs.applyRoleAssignment(*assignment); err != nil {
return err
}
bs.recordExecutedCoreCommand(cmd.VolumeID, "apply_role")
return nil
}
func (bs *BlockService) executeStartReceiverCommand(cmd engine.StartReceiverCommand, assignment *blockvol.BlockVolumeAssignment) error {
if assignment == nil {
return nil
}
if assignment.Path != cmd.VolumeID {
return fmt.Errorf("block service: core start_receiver path mismatch %q != %q", assignment.Path, cmd.VolumeID)
}
if assignment.ReplicaDataAddr == "" || assignment.ReplicaCtrlAddr == "" {
return nil
}
if err := bs.setupReplicaReceiver(assignment.Path, assignment.ReplicaDataAddr, assignment.ReplicaCtrlAddr); err != nil {
return err
}
bs.recordExecutedCoreCommand(cmd.VolumeID, "start_receiver")
bs.applyCoreEvent(engine.ReceiverReadyObserved{ID: cmd.VolumeID})
return nil
}
func (bs *BlockService) executeConfigureShipperCommand(cmd engine.ConfigureShipperCommand) error {
addrs := make([]blockvol.ReplicaAddr, 0, len(cmd.Replicas))
for _, replica := range cmd.Replicas {
if replica.Endpoint.DataAddr == "" || replica.Endpoint.CtrlAddr == "" {
continue
}
addrs = append(addrs, blockvol.ReplicaAddr{
ServerID: replica.ReplicaID,
DataAddr: replica.Endpoint.DataAddr,
CtrlAddr: replica.Endpoint.CtrlAddr,
})
}
if len(addrs) == 0 {
return nil
}
if len(addrs) == 1 {
if err := bs.setupPrimaryReplication(cmd.VolumeID, addrs[0].DataAddr, addrs[0].CtrlAddr); err != nil {
return err
}
} else {
if err := bs.setupPrimaryReplicationMulti(cmd.VolumeID, addrs); err != nil {
return err
}
}
bs.recordExecutedCoreCommand(cmd.VolumeID, "configure_shipper")
bs.applyCoreEvent(engine.ShipperConfiguredObserved{ID: cmd.VolumeID})
if bs.isPrimaryShipperConnected(cmd.VolumeID) {
bs.applyCoreEvent(engine.ShipperConnectedObserved{ID: cmd.VolumeID})
}
return nil
}
func (bs *BlockService) executeInvalidateSessionCommand(cmd engine.InvalidateSessionCommand) error {
if bs == nil || bs.v2Orchestrator == nil || bs.v2Core == nil {
return nil
}
proj, ok := bs.v2Core.Projection(cmd.VolumeID)
if !ok {
return nil
}
for _, replicaID := range proj.ReplicaIDs {
sender := bs.v2Orchestrator.Registry.Sender(replicaID)
if sender == nil {
continue
}
sender.InvalidateSession(cmd.Reason, engine.StateDisconnected)
}
bs.recordExecutedCoreCommand(cmd.VolumeID, "invalidate_session")
return nil
}
func (bs *BlockService) executeStartCatchUpCommand(cmd engine.StartCatchUpCommand) error {
if bs == nil || bs.v2Recovery == nil {
return nil
}
if err := bs.v2Recovery.ExecutePendingCatchUp(cmd.VolumeID, cmd.TargetLSN); err != nil {
return err
}
bs.recordExecutedCoreCommand(cmd.VolumeID, "start_catchup")
return nil
}
func (bs *BlockService) executeStartRebuildCommand(cmd engine.StartRebuildCommand) error {
if bs == nil || bs.v2Recovery == nil {
return nil
}
if err := bs.v2Recovery.ExecutePendingRebuild(cmd.VolumeID, cmd.TargetLSN); err != nil {
return err
}
bs.recordExecutedCoreCommand(cmd.VolumeID, "start_rebuild")
return nil
}
func (bs *BlockService) applyRoleAssignment(a blockvol.BlockVolumeAssignment) error {
if bs == nil || bs.blockStore == nil {
return nil
}
role := blockvol.RoleFromWire(a.Role)
ttl := blockvol.LeaseTTLFromWire(a.LeaseTtlMs)
if err := bs.blockStore.WithVolume(a.Path, func(vol *blockvol.BlockVol) error {
return vol.HandleAssignment(a.Epoch, role, ttl)
}); err != nil {
return err
}
bs.noteRoleApplied(a.Path, role)
bs.applyCoreEvent(engine.RoleApplied{ID: a.Path})
return nil
}
func (bs *BlockService) recordExecutedCoreCommand(path, name string) {
bs.coreExecMu.Lock()
defer bs.coreExecMu.Unlock()
if bs.coreExec == nil {
bs.coreExec = make(map[string][]string)
}
bs.coreExec[path] = append(bs.coreExec[path], name)
}
func (bs *BlockService) coreAssignmentEvent(a blockvol.BlockVolumeAssignment) (engine.AssignmentDelivered, bool) {
role := blockvol.RoleFromWire(a.Role)
ev := engine.AssignmentDelivered{
ID: a.Path,
Epoch: a.Epoch,
}
switch role {
case blockvol.RolePrimary:
ev.Role = engine.RolePrimary
if len(a.ReplicaAddrs) > 0 {
ev.Replicas = make([]engine.ReplicaAssignment, 0, len(a.ReplicaAddrs))
for _, ra := range a.ReplicaAddrs {
if ra.ServerID == "" {
continue
}
ev.Replicas = append(ev.Replicas, engine.ReplicaAssignment{
ReplicaID: fmt.Sprintf("%s/%s", a.Path, ra.ServerID),
Endpoint: engine.Endpoint{
DataAddr: ra.DataAddr,
CtrlAddr: ra.CtrlAddr,
},
})
}
} else if a.ReplicaServerID != "" && a.ReplicaDataAddr != "" {
ev.Replicas = []engine.ReplicaAssignment{{
ReplicaID: fmt.Sprintf("%s/%s", a.Path, a.ReplicaServerID),
Endpoint: engine.Endpoint{
DataAddr: a.ReplicaDataAddr,
CtrlAddr: a.ReplicaCtrlAddr,
},
}}
}
return ev, true
case blockvol.RoleReplica:
ev.Role = engine.RoleReplica
ev.Replicas = []engine.ReplicaAssignment{{
ReplicaID: fmt.Sprintf("%s/%s", a.Path, bs.localServerID),
Endpoint: engine.Endpoint{
DataAddr: a.ReplicaDataAddr,
CtrlAddr: a.ReplicaCtrlAddr,
},
}}
return ev, true
default:
return engine.AssignmentDelivered{}, false
}
}
func (bs *BlockService) isPrimaryShipperConnected(path string) bool {
if bs == nil || bs.blockStore == nil {
return false
}
connected := false
_ = bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
connected = len(vol.ReplicaShipperStates()) > 0 && !vol.Status().ReplicaDegraded
return nil
})
return connected
}
// setupPrimaryReplication configures WAL shipping from primary to replica
// and starts the rebuild server (R1-2).
func (bs *BlockService) setupPrimaryReplication(path, replicaDataAddr, replicaCtrlAddr string) error {
@@ -732,11 +1038,9 @@ func (bs *BlockService) CollectBlockVolumeHeartbeat() []blockvol.BlockVolumeInfo
defer bs.replMu.RUnlock()
for i := range msgs {
if s, ok := bs.replStates[msgs[i].Path]; ok {
if s.publishHealthy {
msgs[i].ReplicaDataAddr = s.replicaDataAddr
msgs[i].ReplicaCtrlAddr = s.replicaCtrlAddr
}
msgs[i].ReplicaDataAddr, msgs[i].ReplicaCtrlAddr = bs.heartbeatReplicaAddrs(msgs[i].Path, s)
}
msgs[i].ReplicaDegraded = bs.heartbeatReplicaDegraded(msgs[i].Path, msgs[i].ReplicaDegraded)
// NVMe publication: report nvme_addr and nqn if NVMe target is running.
if bs.nvmeListenAddr != "" {
msgs[i].NvmeAddr = bs.nvmeListenAddr
@@ -749,6 +1053,48 @@ func (bs *BlockService) CollectBlockVolumeHeartbeat() []blockvol.BlockVolumeInfo
return msgs
}
// heartbeatReplicaAddrs returns the scalar replica transport addresses that
// should be exposed on the current heartbeat surface. On the Phase 15 live path
// it prefers the explicit core projection when present, while preserving the
// older adapter-local fallback for unrebound paths.
func (bs *BlockService) heartbeatReplicaAddrs(path string, state *volReplState) (string, string) {
if state == nil {
return "", ""
}
if proj, ok := bs.CoreProjection(path); ok {
switch proj.Role {
case engine.RolePrimary:
if proj.Readiness.ShipperConfigured {
return state.replicaDataAddr, state.replicaCtrlAddr
}
case engine.RoleReplica:
if proj.Readiness.ReceiverReady {
return state.replicaDataAddr, state.replicaCtrlAddr
}
}
return "", ""
}
if state.publishHealthy {
return state.replicaDataAddr, state.replicaCtrlAddr
}
return "", ""
}
// heartbeatReplicaDegraded returns the bounded degraded bit for the current
// heartbeat surface. On the Phase 15 live path it prefers the core mode when
// present, then falls back to the runtime-local status bit.
func (bs *BlockService) heartbeatReplicaDegraded(path string, current bool) bool {
if proj, ok := bs.CoreProjection(path); ok {
switch proj.Mode.Name {
case engine.ModeDegraded, engine.ModeNeedsRebuild:
return true
default:
return false
}
}
return current
}
// multiReplicaUnchanged checks if the full replica set is unchanged.
func (bs *BlockService) multiReplicaUnchanged(path string, addrs []blockvol.ReplicaAddr) bool {
bs.replMu.RLock()
@@ -849,7 +1195,9 @@ func (bs *BlockService) markReceiverReady(path, dataAddr, ctrlAddr string) {
}
// ReadinessSnapshot reports the service-owned assignment/readiness closure for
// one volume. It keeps v2 publication truth above blockvol's local mechanics.
// one volume. On the Phase 15 live path it prefers the explicit core projection
// for the aligned readiness subset, while `PublishHealthy` remains adapter-local
// until publication ownership is fully rebound.
func (bs *BlockService) ReadinessSnapshot(path string) BlockReadinessSnapshot {
snap := BlockReadinessSnapshot{}
bs.replMu.RLock()
@@ -863,12 +1211,26 @@ func (bs *BlockService) ReadinessSnapshot(path string) BlockReadinessSnapshot {
}
bs.replMu.RUnlock()
if !snap.ShipperConfigured || bs.blockStore == nil {
if proj, ok := bs.CoreProjection(path); ok {
snap.RoleApplied = proj.Readiness.RoleApplied
snap.ReceiverReady = proj.Readiness.ReceiverReady
snap.ShipperConfigured = proj.Readiness.ShipperConfigured
snap.ShipperConnected = proj.Readiness.ShipperConnected
snap.ReplicaEligible = proj.Readiness.ReplicaReady
}
return snap
}
_ = bs.blockStore.WithVolume(path, func(vol *blockvol.BlockVol) error {
snap.ShipperConnected = len(vol.ReplicaShipperStates()) > 0 && !vol.Status().ReplicaDegraded
return nil
})
if proj, ok := bs.CoreProjection(path); ok {
snap.RoleApplied = proj.Readiness.RoleApplied
snap.ReceiverReady = proj.Readiness.ReceiverReady
snap.ShipperConfigured = proj.Readiness.ShipperConfigured
snap.ShipperConnected = proj.Readiness.ShipperConnected
snap.ReplicaEligible = proj.Readiness.ReplicaReady
}
return snap
}
+37 -16
View File
@@ -19,6 +19,7 @@ type ShipperDebugInfo struct {
type BlockVolumeDebugInfo struct {
Path string `json:"path"`
Role string `json:"role"`
Mode string `json:"mode,omitempty"`
Epoch uint64 `json:"epoch"`
HeadLSN uint64 `json:"head_lsn"`
Degraded bool `json:"degraded"`
@@ -28,10 +29,45 @@ type BlockVolumeDebugInfo struct {
ShipperConnected bool `json:"shipper_connected"`
ReplicaEligible bool `json:"replica_eligible"`
PublishHealthy bool `json:"publish_healthy"`
PublicationReason string `json:"publication_reason,omitempty"`
Shippers []ShipperDebugInfo `json:"shippers,omitempty"`
Timestamp string `json:"timestamp"`
}
// DebugInfoForVolume returns the current debug surface for one volume. When the
// Phase 15 core projection exists on the live path, this surface prefers the
// core-owned projection truth over adapter-local convenience flags.
func (bs *BlockService) DebugInfoForVolume(path string, vol *blockvol.BlockVol) BlockVolumeDebugInfo {
status := vol.Status()
readiness := bs.ReadinessSnapshot(path)
info := BlockVolumeDebugInfo{
Path: path,
Role: status.Role.String(),
Epoch: status.Epoch,
HeadLSN: status.WALHeadLSN,
Degraded: status.ReplicaDegraded,
RoleApplied: readiness.RoleApplied,
ReceiverReady: readiness.ReceiverReady,
ShipperConfigured: readiness.ShipperConfigured,
ShipperConnected: readiness.ShipperConnected,
ReplicaEligible: readiness.ReplicaEligible,
PublishHealthy: readiness.PublishHealthy,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
}
if proj, ok := bs.CoreProjection(path); ok {
info.Role = string(proj.Role)
info.Mode = string(proj.Mode.Name)
info.RoleApplied = proj.Readiness.RoleApplied
info.ReceiverReady = proj.Readiness.ReceiverReady
info.ShipperConfigured = proj.Readiness.ShipperConfigured
info.ShipperConnected = proj.Readiness.ShipperConnected
info.ReplicaEligible = proj.Readiness.ReplicaReady
info.PublishHealthy = proj.Publication.Healthy
info.PublicationReason = proj.Publication.Reason
}
return info
}
// debugBlockShipperHandler returns real-time shipper state for all block volumes.
// Unlike the master's replica_degraded (heartbeat-lagged), this reads directly
// from the shipper's atomic state field — no heartbeat delay.
@@ -53,22 +89,7 @@ func (vs *VolumeServer) debugBlockShipperHandler(w http.ResponseWriter, r *http.
var infos []BlockVolumeDebugInfo
store.IterateBlockVolumes(func(path string, vol *blockvol.BlockVol) {
status := vol.Status()
readiness := vs.blockService.ReadinessSnapshot(path)
info := BlockVolumeDebugInfo{
Path: path,
Role: status.Role.String(),
Epoch: status.Epoch,
HeadLSN: status.WALHeadLSN,
Degraded: status.ReplicaDegraded,
RoleApplied: readiness.RoleApplied,
ReceiverReady: readiness.ReceiverReady,
ShipperConfigured: readiness.ShipperConfigured,
ShipperConnected: readiness.ShipperConnected,
ReplicaEligible: readiness.ReplicaEligible,
PublishHealthy: readiness.PublishHealthy,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
}
info := vs.blockService.DebugInfoForVolume(path, vol)
// Get per-shipper state from ShipperGroup if available.
sg := vol.GetShipperGroup()
+627 -5
View File
@@ -2,8 +2,10 @@ package weed_server
import (
"path/filepath"
"reflect"
"testing"
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
@@ -66,11 +68,14 @@ func newTestBlockServiceDirect(t *testing.T) *BlockService {
store := storage.NewBlockVolumeStore()
t.Cleanup(func() { store.Close() })
return &BlockService{
blockStore: store,
blockDir: dir,
listenAddr: "0.0.0.0:3260",
iqnPrefix: "iqn.2024-01.com.seaweedfs:vol.",
replStates: make(map[string]*volReplState),
blockStore: store,
blockDir: dir,
listenAddr: "0.0.0.0:3260",
iqnPrefix: "iqn.2024-01.com.seaweedfs:vol.",
replStates: make(map[string]*volReplState),
v2Core: engine.NewCoreEngine(),
coreProj: make(map[string]engine.PublicationProjection),
localServerID: "vs-test",
}
}
@@ -170,6 +175,623 @@ func TestBlockService_ProcessAssignment_WithReplicaAddrs(t *testing.T) {
}
}
func TestBlockService_ApplyAssignments_UpdatesCoreProjectionReplicaPath(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-core-replica")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
},
})
if len(errs) != 1 {
t.Fatalf("errs len=%d", len(errs))
}
if errs[0] != nil {
t.Fatalf("apply assignment: %v", errs[0])
}
proj, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected core projection to be cached on narrow live path")
}
if proj.Role != engine.RoleReplica {
t.Fatalf("role=%s", proj.Role)
}
if proj.Mode.Name != engine.ModeReplicaReady {
t.Fatalf("mode=%s", proj.Mode.Name)
}
if !proj.Readiness.RoleApplied || !proj.Readiness.ReceiverReady {
t.Fatalf("readiness=%+v", proj.Readiness)
}
if proj.Publication.Healthy {
t.Fatal("replica-ready narrow path must not overclaim publish healthy")
}
if proj.Publication.Reason != "replica_not_primary" {
t.Fatalf("publication_reason=%q", proj.Publication.Reason)
}
coreProj, ok := bs.V2Core().Projection(path)
if !ok {
t.Fatal("expected core projection from explicit V2 core")
}
if !reflect.DeepEqual(proj, coreProj) {
t.Fatalf("adapter cache diverged from core projection:\ncache=%+v\ncore=%+v", proj, coreProj)
}
if mismatches := bs.CoreProjectionMismatches(path); len(mismatches) != 0 {
t.Fatalf("readiness/core mismatches=%v", mismatches)
}
}
func TestBlockService_ApplyAssignments_UpdatesCoreProjectionPrimaryPath(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-core-primary")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
},
})
if len(errs) != 1 {
t.Fatalf("errs len=%d", len(errs))
}
if errs[0] != nil {
t.Fatalf("apply assignment: %v", errs[0])
}
proj, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected core projection to be cached on narrow live path")
}
if proj.Role != engine.RolePrimary {
t.Fatalf("role=%s", proj.Role)
}
if proj.Readiness.RoleApplied != true {
t.Fatalf("readiness=%+v", proj.Readiness)
}
if !proj.Readiness.ShipperConfigured {
t.Fatalf("readiness=%+v", proj.Readiness)
}
if proj.Publication.Healthy {
t.Fatal("primary assignment without durable boundary must not overclaim publish healthy")
}
coreProj, ok := bs.V2Core().Projection(path)
if !ok {
t.Fatal("expected core projection from explicit V2 core")
}
if !reflect.DeepEqual(proj, coreProj) {
t.Fatalf("adapter cache diverged from core projection:\ncache=%+v\ncore=%+v", proj, coreProj)
}
if mismatches := bs.CoreProjectionMismatches(path); len(mismatches) != 0 {
t.Fatalf("readiness/core mismatches=%v", mismatches)
}
}
func TestBlockService_ApplyAssignments_RepeatedUnchangedStaysInSyncWithCore(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-core-repeat")
assign := blockvol.BlockVolumeAssignment{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
}
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{assign})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("first apply errs=%v", errs)
}
firstCache, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected cached projection after first apply")
}
firstCore, ok := bs.V2Core().Projection(path)
if !ok {
t.Fatal("expected core projection after first apply")
}
if !reflect.DeepEqual(firstCache, firstCore) {
t.Fatalf("first cache/core mismatch:\ncache=%+v\ncore=%+v", firstCache, firstCore)
}
errs = bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{assign})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("second apply errs=%v", errs)
}
secondCache, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected cached projection after second apply")
}
secondCore, ok := bs.V2Core().Projection(path)
if !ok {
t.Fatal("expected core projection after second apply")
}
if !reflect.DeepEqual(secondCache, secondCore) {
t.Fatalf("second cache/core mismatch:\ncache=%+v\ncore=%+v", secondCache, secondCore)
}
if !reflect.DeepEqual(firstCache, secondCache) {
t.Fatalf("unchanged assignment should not split cached projection:\nfirst=%+v\nsecond=%+v", firstCache, secondCache)
}
if mismatches := bs.CoreProjectionMismatches(path); len(mismatches) != 0 {
t.Fatalf("readiness/core mismatches=%v", mismatches)
}
}
func TestBlockService_ApplyAssignments_ExecutesCoreCommands_PrimaryRoleApplyAndConfigureShipper(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-core-cmd-primary")
assign := blockvol.BlockVolumeAssignment{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
}
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{assign})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply errs=%v", errs)
}
vol, ok := bs.blockStore.GetBlockVolume(path)
if !ok {
t.Fatal("volume not found")
}
status := vol.Status()
if status.Role != blockvol.RolePrimary || status.Epoch != 1 {
t.Fatalf("status=%+v", status)
}
if got := bs.ExecutedCoreCommands(path); !reflect.DeepEqual(got, []string{"apply_role", "configure_shipper"}) {
t.Fatalf("executed commands=%v", got)
}
errs = bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{assign})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("second apply errs=%v", errs)
}
if got := bs.ExecutedCoreCommands(path); !reflect.DeepEqual(got, []string{"apply_role", "configure_shipper"}) {
t.Fatalf("unchanged assignment should not re-execute command chain, got %v", got)
}
}
func TestBlockService_ApplyAssignments_ExecutesCoreCommands_ReplicaRoleAndReceiver(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-core-cmd-replica")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
}})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply errs=%v", errs)
}
vol, ok := bs.blockStore.GetBlockVolume(path)
if !ok {
t.Fatal("volume not found")
}
status := vol.Status()
if status.Role != blockvol.RoleReplica || status.Epoch != 1 {
t.Fatalf("status=%+v", status)
}
if got := bs.ExecutedCoreCommands(path); !reflect.DeepEqual(got, []string{"apply_role", "start_receiver"}) {
t.Fatalf("executed commands=%v", got)
}
readiness := bs.ReadinessSnapshot(path)
if !readiness.RoleApplied || !readiness.ReceiverReady {
t.Fatalf("readiness=%+v", readiness)
}
}
func TestBlockService_BarrierRejected_ExecutesCoreInvalidateSession(t *testing.T) {
bs := newTestBlockServiceDirect(t)
bs.v2Bridge = newTestControlBridge()
bs.v2Orchestrator = newTestOrchestrator()
path := createTestVolDirect(t, bs, "vol-core-cmd-invalidate")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
}})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply errs=%v", errs)
}
replicaID := path + "/vs-2"
sender := bs.v2Orchestrator.Registry.Sender(replicaID)
if sender == nil {
t.Fatal("sender not found")
}
if !sender.HasActiveSession() {
t.Fatal("sender should start with active session")
}
bs.applyCoreEvent(engine.BarrierRejected{ID: path, Reason: "timeout"})
if sender.HasActiveSession() {
t.Fatal("sender session should be invalidated by core command")
}
if got := bs.ExecutedCoreCommands(path); !reflect.DeepEqual(got, []string{"apply_role", "configure_shipper", "invalidate_session"}) {
t.Fatalf("executed commands=%v", got)
}
}
func TestBlockService_BarrierRejected_DoesNotReexecuteInvalidateOnSameReason(t *testing.T) {
bs := newTestBlockServiceDirect(t)
bs.v2Bridge = newTestControlBridge()
bs.v2Orchestrator = newTestOrchestrator()
path := createTestVolDirect(t, bs, "vol-core-cmd-invalidate-repeat")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
}})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply errs=%v", errs)
}
bs.applyCoreEvent(engine.BarrierRejected{ID: path, Reason: "timeout"})
first := bs.ExecutedCoreCommands(path)
bs.applyCoreEvent(engine.BarrierRejected{ID: path, Reason: "timeout"})
second := bs.ExecutedCoreCommands(path)
if !reflect.DeepEqual(first, []string{"apply_role", "configure_shipper", "invalidate_session"}) {
t.Fatalf("first executed commands=%v", first)
}
if !reflect.DeepEqual(second, first) {
t.Fatalf("repeated failure should not re-execute invalidate_session: first=%v second=%v", first, second)
}
}
func TestBlockService_DebugInfoForVolume_UsesCoreProjectionPrimaryPath(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-debug-primary")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
readiness := bs.ReadinessSnapshot(path)
if !readiness.PublishHealthy {
t.Fatalf("expected adapter-local readiness to still report publish healthy, got %+v", readiness)
}
vol, ok := bs.blockStore.GetBlockVolume(path)
if !ok {
t.Fatal("volume not found")
}
info := bs.DebugInfoForVolume(path, vol)
proj, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected core projection for debug surface")
}
if info.Role != string(proj.Role) {
t.Fatalf("role=%q projection_role=%q", info.Role, proj.Role)
}
if info.Mode != string(proj.Mode.Name) {
t.Fatalf("mode=%q projection_mode=%q", info.Mode, proj.Mode.Name)
}
if info.PublishHealthy != proj.Publication.Healthy {
t.Fatalf("publish_healthy=%v projection=%v", info.PublishHealthy, proj.Publication.Healthy)
}
if info.PublicationReason != proj.Publication.Reason {
t.Fatalf("publication_reason=%q projection_reason=%q", info.PublicationReason, proj.Publication.Reason)
}
if info.PublishHealthy {
t.Fatalf("debug surface must not overclaim healthy on primary path without durable boundary: %+v", info)
}
}
func TestBlockService_DebugInfoForVolume_UsesCoreProjectionReplicaPath(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-debug-replica")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
vol, ok := bs.blockStore.GetBlockVolume(path)
if !ok {
t.Fatal("volume not found")
}
info := bs.DebugInfoForVolume(path, vol)
proj, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected core projection for debug surface")
}
if info.Role != string(proj.Role) {
t.Fatalf("role=%q projection_role=%q", info.Role, proj.Role)
}
if info.Mode != string(proj.Mode.Name) {
t.Fatalf("mode=%q projection_mode=%q", info.Mode, proj.Mode.Name)
}
if info.ReceiverReady != proj.Readiness.ReceiverReady {
t.Fatalf("receiver_ready=%v projection=%v", info.ReceiverReady, proj.Readiness.ReceiverReady)
}
if info.ReplicaEligible != proj.Readiness.ReplicaReady {
t.Fatalf("replica_eligible=%v projection_ready=%v", info.ReplicaEligible, proj.Readiness.ReplicaReady)
}
if info.PublishHealthy != proj.Publication.Healthy {
t.Fatalf("publish_healthy=%v projection=%v", info.PublishHealthy, proj.Publication.Healthy)
}
if info.PublicationReason != proj.Publication.Reason {
t.Fatalf("publication_reason=%q projection_reason=%q", info.PublicationReason, proj.Publication.Reason)
}
}
func TestBlockService_CollectBlockVolumeHeartbeat_PrimaryUsesCoreReadinessGate(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-heartbeat-primary")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
proj, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected core projection for heartbeat surface")
}
if !proj.Readiness.ShipperConfigured {
t.Fatalf("projection readiness=%+v", proj.Readiness)
}
if proj.Publication.Healthy {
t.Fatalf("primary projection must still be publication-unhealthy here: %+v", proj.Publication)
}
bs.replMu.Lock()
state := bs.replStates[path]
if state == nil {
bs.replMu.Unlock()
t.Fatal("missing repl state")
}
state.publishHealthy = false
bs.replMu.Unlock()
msgs := bs.CollectBlockVolumeHeartbeat()
msg := findHeartbeatMsg(msgs, path)
if msg == nil {
t.Fatal("volume missing from heartbeat")
}
if msg.ReplicaDataAddr != "10.0.0.2:4260" {
t.Fatalf("ReplicaDataAddr=%q", msg.ReplicaDataAddr)
}
if msg.ReplicaCtrlAddr != "10.0.0.2:4261" {
t.Fatalf("ReplicaCtrlAddr=%q", msg.ReplicaCtrlAddr)
}
}
func TestBlockService_CollectBlockVolumeHeartbeat_ReplicaUsesCoreReadinessGate(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-heartbeat-replica")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
proj, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected core projection for heartbeat surface")
}
if !proj.Readiness.ReceiverReady {
t.Fatalf("projection readiness=%+v", proj.Readiness)
}
if proj.Publication.Healthy {
t.Fatalf("replica projection must not own healthy publication: %+v", proj.Publication)
}
bs.replMu.Lock()
state := bs.replStates[path]
if state == nil {
bs.replMu.Unlock()
t.Fatal("missing repl state")
}
expectedData := state.replicaDataAddr
expectedCtrl := state.replicaCtrlAddr
state.publishHealthy = false
bs.replMu.Unlock()
msgs := bs.CollectBlockVolumeHeartbeat()
msg := findHeartbeatMsg(msgs, path)
if msg == nil {
t.Fatal("volume missing from heartbeat")
}
if msg.ReplicaDataAddr != expectedData {
t.Fatalf("ReplicaDataAddr=%q expected=%q", msg.ReplicaDataAddr, expectedData)
}
if msg.ReplicaCtrlAddr != expectedCtrl {
t.Fatalf("ReplicaCtrlAddr=%q expected=%q", msg.ReplicaCtrlAddr, expectedCtrl)
}
}
func TestBlockService_ReadinessSnapshot_PrefersCoreProjectionPrimaryFields(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-readiness-primary")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RolePrimary),
LeaseTtlMs: 30000,
ReplicaServerID: "vs-2",
ReplicaDataAddr: "10.0.0.2:4260",
ReplicaCtrlAddr: "10.0.0.2:4261",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.replMu.Lock()
state := bs.replStates[path]
if state == nil {
bs.replMu.Unlock()
t.Fatal("missing repl state")
}
state.roleApplied = false
state.shipperConfigured = false
state.publishHealthy = false
bs.replMu.Unlock()
snap := bs.ReadinessSnapshot(path)
if !snap.RoleApplied {
t.Fatalf("expected snapshot role_applied from core projection, got %+v", snap)
}
if !snap.ShipperConfigured {
t.Fatalf("expected snapshot shipper_configured from core projection, got %+v", snap)
}
if snap.PublishHealthy {
t.Fatalf("publish_healthy should remain adapter-local on readiness snapshot, got %+v", snap)
}
}
func TestBlockService_ReadinessSnapshot_PrefersCoreProjectionReplicaFields(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-readiness-replica")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.replMu.Lock()
state := bs.replStates[path]
if state == nil {
bs.replMu.Unlock()
t.Fatal("missing repl state")
}
state.receiverReady = false
state.replicaEligible = false
state.publishHealthy = false
bs.replMu.Unlock()
snap := bs.ReadinessSnapshot(path)
if !snap.ReceiverReady {
t.Fatalf("expected snapshot receiver_ready from core projection, got %+v", snap)
}
if !snap.ReplicaEligible {
t.Fatalf("expected snapshot replica_eligible from core projection, got %+v", snap)
}
if snap.PublishHealthy {
t.Fatalf("publish_healthy should remain adapter-local on readiness snapshot, got %+v", snap)
}
}
func TestBlockService_HeartbeatReplicaDegraded_UsesCoreMode(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol-heartbeat-degraded")
errs := bs.ApplyAssignments([]blockvol.BlockVolumeAssignment{
{
Path: path,
Epoch: 1,
Role: blockvol.RoleToWire(blockvol.RoleReplica),
LeaseTtlMs: 30000,
ReplicaDataAddr: "127.0.0.1:0",
ReplicaCtrlAddr: "127.0.0.1:0",
},
})
if len(errs) != 1 || errs[0] != nil {
t.Fatalf("apply assignment errs=%v", errs)
}
bs.applyCoreEvent(engine.NeedsRebuildObserved{ID: path, Reason: "gap_too_large"})
proj, ok := bs.CoreProjection(path)
if !ok {
t.Fatal("expected core projection for heartbeat degraded mapping")
}
if proj.Mode.Name != engine.ModeNeedsRebuild {
t.Fatalf("mode=%s", proj.Mode.Name)
}
if !bs.heartbeatReplicaDegraded(path, false) {
t.Fatalf("expected degraded bit from core mode even when current=false, projection=%+v", proj)
}
}
func TestBlockService_HeartbeatIncludesReplicaAddrs(t *testing.T) {
bs := newTestBlockServiceDirect(t)
path := createTestVolDirect(t, bs, "vol1")