-
Notifications
You must be signed in to change notification settings - Fork 63
server: fence capture writes with etcd and P2P leases #6092
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ti-chi-bot
merged 44 commits into
pingcap:master
from
asddongmen:0826-capture-write-lease
Sep 4, 2026
Merged
Changes from all commits
Commits
Show all changes
44 commits
Select commit
Hold shift + click to select a range
d8e27ff
orchestrator: extend capture removal grace to 10 seconds
asddongmen 8f20728
server: add capture write lease gate
asddongmen 13c0965
coordinator,maintainer: add capture P2P write lease
asddongmen ee020e8
downstreamadapter: enforce capture write lease
asddongmen ecd0920
server: bound TTL checks by the write proof deadline
asddongmen 3866cf3
coordinator: reject capture epoch changes before rebootstrap
asddongmen f1a2546
server,coordinator: expose capture write lease metrics
asddongmen 94fc221
coordinator,maintainer: negotiate P2P write lease per node
asddongmen 7eed969
mysql: enforce write lease at DML execution
asddongmen 57cddaa
mysql: expose last write admission timestamp
asddongmen 1ef8092
mysql: add post-admission hang failpoint
asddongmen b8ae661
coordinator: add write lease response fault hooks
asddongmen db84fdf
mysql: make post-admission hang observable
asddongmen eaff1fc
coordinator: trigger lease response faults with markers
asddongmen d262f64
coordinator,server: observe write lease handshake
asddongmen c9c055f
coordinator: keep lease grants flowing without fault markers
asddongmen c196f2e
messaging: test write lease response round trip
asddongmen af654f8
coordinator: inject lease faults only on grants
asddongmen 0f920e9
mysql: make admitted commit hang deterministic
asddongmen 133d606
mysql: remove admitted transaction test hook
asddongmen ecad0f0
*: fix write lease CI checks
asddongmen 0a6e4dd
server,writelease: document write gate constants
asddongmen c979ed5
server: avoid fencing live etcd leases
asddongmen 4297b6e
tests,coordinator: add capture write-lease coverage
asddongmen 36e0fcf
writelease: notify waiters only when writable
asddongmen ae67aad
mysql: avoid holding connections while waiting for lease
asddongmen 2c7d3e1
coordinator: retry witnesses before write lease expires
asddongmen 483e181
coordinator,mysql: address capture write lease review findings
asddongmen 5ba207f
writelease: add optional transport admission helpers
asddongmen 57e018b
sink: gate Kafka writes at producer boundary
asddongmen 1a70552
sink: gate Pulsar writes at producer boundary
asddongmen 90d6646
sink: gate storage writes at publication boundary
asddongmen 5f5d4fc
redo: gate persistence at publication boundary
asddongmen 9dcf3c9
sink: require transport write-gate admission
asddongmen e4b59cd
Merge remote-tracking branch 'upstream/master' into 0826-capture-writ…
asddongmen ddacb96
docs: add capture write lease design
asddongmen dcfe564
docs: remove capture write lease PNGs
asddongmen 411f58b
coordinator,tests: make write lease CI deterministic
asddongmen f74e149
tests: expect capture exit after PD loss
asddongmen b401472
tests: expect capture exit in synced status case
asddongmen 7d87486
maintainer,tests: address write lease review comments
asddongmen fddb99e
coordinator,redo,tests: address write lease review
asddongmen 23fcd27
common,server: fix unit test failures
asddongmen 139844d
tests: reduce write lease YCSB workload
asddongmen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,270 @@ | ||
| // Copyright 2026 PingCAP, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package coordinator | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "crypto/rand" | ||
| "slices" | ||
| "time" | ||
|
|
||
| "github.com/pingcap/ticdc/heartbeatpb" | ||
| "github.com/pingcap/ticdc/pkg/messaging" | ||
| "github.com/pingcap/ticdc/pkg/metrics" | ||
| "github.com/pingcap/ticdc/pkg/node" | ||
| "github.com/pingcap/ticdc/pkg/writelease" | ||
| ) | ||
|
|
||
| const ( | ||
| witnessNonceSize = 16 | ||
| witnessChallengeTimeout = time.Second | ||
| ) | ||
|
|
||
| type captureLeaseNodeState struct { | ||
| nodeEpoch uint64 | ||
| lastRequestSeq uint64 | ||
| } | ||
|
|
||
| type pendingWitnessChallenge struct { | ||
| selfNodeEpoch uint64 | ||
| selfRequestSeq uint64 | ||
| witnessNodeID node.ID | ||
| witnessNodeEpoch uint64 | ||
| nonce []byte | ||
| expiresAt time.Time | ||
| } | ||
|
|
||
| // captureWriteLeaseController is owned by Controller's single event-handling | ||
| // goroutine after construction. Its mutable state is not safe for concurrent use. | ||
| type captureWriteLeaseController struct { | ||
| coordinatorVersion int64 | ||
| selfNodeID node.ID | ||
| now func() time.Time | ||
| nonce func([]byte) (int, error) | ||
|
|
||
| nodes map[node.ID]*captureLeaseNodeState | ||
| p2pCapableNodes map[node.ID]struct{} | ||
| p2pLeaseEnabled bool | ||
| pendingWitness *pendingWitnessChallenge | ||
| nextWitnessIndex int | ||
| } | ||
|
|
||
| func newCaptureWriteLeaseController(version int64, selfNodeID node.ID) *captureWriteLeaseController { | ||
| return &captureWriteLeaseController{ | ||
| coordinatorVersion: version, | ||
| selfNodeID: selfNodeID, | ||
| now: time.Now, | ||
| nonce: rand.Read, | ||
| nodes: make(map[node.ID]*captureLeaseNodeState), | ||
| p2pCapableNodes: make(map[node.ID]struct{}), | ||
| } | ||
| } | ||
|
|
||
| func (c *captureWriteLeaseController) observeNodeCapability(id node.ID, version uint32) { | ||
| if version == heartbeatpb.CurrentWriteLeaseProtocolVersion { | ||
| c.p2pCapableNodes[id] = struct{}{} | ||
| } else { | ||
| delete(c.p2pCapableNodes, id) | ||
| } | ||
| } | ||
|
|
||
| // updateClusterMode enables P2P only when every active capture has reported | ||
| // support for the current protocol. A missing capability is treated as legacy. | ||
| func (c *captureWriteLeaseController) updateClusterMode(activeNodes []node.ID) { | ||
| p2pEnabled := len(activeNodes) > 0 | ||
| for _, id := range activeNodes { | ||
| if _, ok := c.p2pCapableNodes[id]; !ok { | ||
| p2pEnabled = false | ||
| break | ||
| } | ||
| } | ||
| if c.p2pLeaseEnabled && !p2pEnabled { | ||
| c.pendingWitness = nil | ||
| } | ||
| c.p2pLeaseEnabled = p2pEnabled | ||
| } | ||
|
|
||
| func (c *captureWriteLeaseController) handleHeartbeat( | ||
| from node.ID, | ||
| heartbeat *heartbeatpb.NodeHeartbeat, | ||
| initializedNodes []node.ID, | ||
| ) []*messaging.TargetMessage { | ||
| if heartbeat.GetWriteLeaseProtocolVersion() != heartbeatpb.CurrentWriteLeaseProtocolVersion || | ||
| heartbeat.GetWriteLeaseRequestSeq() == 0 || heartbeat.GetNodeEpoch() == 0 || | ||
| heartbeat.GetLiveness() == heartbeatpb.NodeLiveness_STOPPING { | ||
| return nil | ||
| } | ||
|
|
||
| state := c.nodes[from] | ||
| if state == nil { | ||
| state = &captureLeaseNodeState{nodeEpoch: heartbeat.GetNodeEpoch()} | ||
| c.nodes[from] = state | ||
| } else if state.nodeEpoch != heartbeat.GetNodeEpoch() { | ||
| // A process epoch is immutable while the same capture ID remains in the | ||
| // bootstrapper. Accepting an epoch change here would let a delayed old | ||
| // process heartbeat roll the coordinator's fencing state backwards. | ||
| return nil | ||
| } | ||
| if heartbeat.GetWriteLeaseRequestSeq() <= state.lastRequestSeq { | ||
| return nil | ||
| } | ||
| state.lastRequestSeq = heartbeat.GetWriteLeaseRequestSeq() | ||
|
|
||
| messages := c.handleWitnessAck(from, heartbeat) | ||
| if from != c.selfNodeID { | ||
| return append(messages, c.newGrant(from, heartbeat.GetNodeEpoch(), heartbeat.GetWriteLeaseRequestSeq())) | ||
| } | ||
|
|
||
| return append(messages, c.handleSelfHeartbeat(heartbeat, initializedNodes)...) | ||
| } | ||
|
|
||
| func (c *captureWriteLeaseController) handleWitnessAck( | ||
| from node.ID, | ||
| heartbeat *heartbeatpb.NodeHeartbeat, | ||
| ) []*messaging.TargetMessage { | ||
| ack := heartbeat.GetWriteLeaseWitnessAck() | ||
| pending := c.pendingWitness | ||
| if ack == nil || pending == nil { | ||
| return nil | ||
| } | ||
| if !c.now().Before(pending.expiresAt) { | ||
| c.pendingWitness = nil | ||
| return nil | ||
| } | ||
| if from != pending.witnessNodeID || | ||
| heartbeat.GetNodeEpoch() != pending.witnessNodeEpoch || | ||
| ack.GetCoordinatorVersion() != c.coordinatorVersion || | ||
| ack.GetCoordinatorNodeEpoch() != pending.selfNodeEpoch || | ||
| ack.GetSelfRequestSeq() != pending.selfRequestSeq || | ||
| ack.GetWitnessNodeEpoch() != pending.witnessNodeEpoch || | ||
| !bytes.Equal(ack.GetNonce(), pending.nonce) { | ||
| return nil | ||
| } | ||
|
|
||
| selfState := c.nodes[c.selfNodeID] | ||
| if selfState == nil || selfState.nodeEpoch != pending.selfNodeEpoch { | ||
| c.pendingWitness = nil | ||
| return nil | ||
| } | ||
| c.pendingWitness = nil | ||
| return []*messaging.TargetMessage{ | ||
| c.newGrant(c.selfNodeID, pending.selfNodeEpoch, pending.selfRequestSeq), | ||
| } | ||
| } | ||
|
|
||
| func (c *captureWriteLeaseController) handleSelfHeartbeat( | ||
| heartbeat *heartbeatpb.NodeHeartbeat, | ||
| initializedNodes []node.ID, | ||
| ) []*messaging.TargetMessage { | ||
| if !c.p2pLeaseEnabled { | ||
| metrics.CaptureP2PWitnessAvailable.Set(0) | ||
| return []*messaging.TargetMessage{ | ||
| c.newGrant(c.selfNodeID, heartbeat.GetNodeEpoch(), heartbeat.GetWriteLeaseRequestSeq()), | ||
| } | ||
| } | ||
|
|
||
| remoteExists := false | ||
| witnesses := make([]node.ID, 0, len(initializedNodes)) | ||
| for _, id := range initializedNodes { | ||
| if id == c.selfNodeID { | ||
| continue | ||
| } | ||
| remoteExists = true | ||
| if state := c.nodes[id]; state != nil && state.nodeEpoch != 0 { | ||
| witnesses = append(witnesses, id) | ||
| } | ||
| } | ||
| if !remoteExists { | ||
| metrics.CaptureP2PWitnessAvailable.Set(0) | ||
| return []*messaging.TargetMessage{ | ||
| c.newGrant(c.selfNodeID, heartbeat.GetNodeEpoch(), heartbeat.GetWriteLeaseRequestSeq()), | ||
| } | ||
| } | ||
| if len(witnesses) == 0 { | ||
| metrics.CaptureP2PWitnessAvailable.Set(0) | ||
| return nil | ||
| } | ||
| metrics.CaptureP2PWitnessAvailable.Set(1) | ||
|
|
||
| if c.pendingWitness != nil { | ||
| if c.now().Before(c.pendingWitness.expiresAt) { | ||
| return nil | ||
| } | ||
| c.pendingWitness = nil | ||
| } | ||
|
|
||
| slices.Sort(witnesses) | ||
| witness := witnesses[c.nextWitnessIndex%len(witnesses)] | ||
| c.nextWitnessIndex++ | ||
| witnessEpoch := c.nodes[witness].nodeEpoch | ||
| nonce := make([]byte, witnessNonceSize) | ||
| if _, err := c.nonce(nonce); err != nil { | ||
| return nil | ||
| } | ||
|
|
||
| c.pendingWitness = &pendingWitnessChallenge{ | ||
| selfNodeEpoch: heartbeat.GetNodeEpoch(), | ||
| selfRequestSeq: heartbeat.GetWriteLeaseRequestSeq(), | ||
| witnessNodeID: witness, | ||
| witnessNodeEpoch: witnessEpoch, | ||
| nonce: nonce, | ||
| expiresAt: c.now().Add(witnessChallengeTimeout), | ||
| } | ||
| response := &heartbeatpb.NodeHeartbeatResponse{ | ||
| CoordinatorVersion: c.coordinatorVersion, | ||
| TargetNodeEpoch: witnessEpoch, | ||
| WitnessChallenge: &heartbeatpb.WriteLeaseWitnessChallenge{ | ||
| CoordinatorVersion: c.coordinatorVersion, | ||
| CoordinatorNodeEpoch: heartbeat.GetNodeEpoch(), | ||
| SelfRequestSeq: heartbeat.GetWriteLeaseRequestSeq(), | ||
| WitnessNodeEpoch: witnessEpoch, | ||
| Nonce: append([]byte(nil), nonce...), | ||
| }, | ||
| } | ||
| return []*messaging.TargetMessage{ | ||
| messaging.NewSingleTargetMessage(witness, messaging.MaintainerManagerTopic, response), | ||
| } | ||
| } | ||
|
|
||
| func (c *captureWriteLeaseController) newGrant( | ||
| target node.ID, | ||
| targetNodeEpoch uint64, | ||
| requestSeq uint64, | ||
| ) *messaging.TargetMessage { | ||
| // A zero duration is an authenticated, sequence-checked signal that the | ||
| // cluster is in mixed-version mode and P2P enforcement must stay disabled. | ||
| leaseDurationMs := uint64(0) | ||
| if c.p2pLeaseEnabled { | ||
| leaseDurationMs = uint64(writelease.P2PLeaseDuration.Milliseconds()) | ||
| } | ||
| return messaging.NewSingleTargetMessage( | ||
| target, | ||
| messaging.MaintainerManagerTopic, | ||
| &heartbeatpb.NodeHeartbeatResponse{ | ||
| CoordinatorVersion: c.coordinatorVersion, | ||
| TargetNodeEpoch: targetNodeEpoch, | ||
| RequestSeq: requestSeq, | ||
| LeaseDurationMs: leaseDurationMs, | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| func (c *captureWriteLeaseController) removeNode(id node.ID) { | ||
| delete(c.nodes, id) | ||
| delete(c.p2pCapableNodes, id) | ||
| if c.pendingWitness != nil && | ||
| (c.pendingWitness.witnessNodeID == id || id == c.selfNodeID) { | ||
| c.pendingWitness = nil | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.