Skip to content
Merged
Show file tree
Hide file tree
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 Aug 26, 2026
8f20728
server: add capture write lease gate
asddongmen Aug 26, 2026
13c0965
coordinator,maintainer: add capture P2P write lease
asddongmen Aug 26, 2026
ee020e8
downstreamadapter: enforce capture write lease
asddongmen Aug 26, 2026
ecd0920
server: bound TTL checks by the write proof deadline
asddongmen Aug 26, 2026
3866cf3
coordinator: reject capture epoch changes before rebootstrap
asddongmen Aug 26, 2026
f1a2546
server,coordinator: expose capture write lease metrics
asddongmen Aug 26, 2026
94fc221
coordinator,maintainer: negotiate P2P write lease per node
asddongmen Aug 26, 2026
7eed969
mysql: enforce write lease at DML execution
asddongmen Aug 26, 2026
57cddaa
mysql: expose last write admission timestamp
asddongmen Aug 26, 2026
1ef8092
mysql: add post-admission hang failpoint
asddongmen Aug 26, 2026
b8ae661
coordinator: add write lease response fault hooks
asddongmen Aug 26, 2026
db84fdf
mysql: make post-admission hang observable
asddongmen Aug 26, 2026
eaff1fc
coordinator: trigger lease response faults with markers
asddongmen Aug 26, 2026
d262f64
coordinator,server: observe write lease handshake
asddongmen Aug 26, 2026
c9c055f
coordinator: keep lease grants flowing without fault markers
asddongmen Aug 26, 2026
c196f2e
messaging: test write lease response round trip
asddongmen Aug 26, 2026
af654f8
coordinator: inject lease faults only on grants
asddongmen Aug 26, 2026
0f920e9
mysql: make admitted commit hang deterministic
asddongmen Aug 26, 2026
133d606
mysql: remove admitted transaction test hook
asddongmen Aug 27, 2026
ecad0f0
*: fix write lease CI checks
asddongmen Aug 27, 2026
0a6e4dd
server,writelease: document write gate constants
asddongmen Aug 28, 2026
c979ed5
server: avoid fencing live etcd leases
asddongmen Aug 28, 2026
4297b6e
tests,coordinator: add capture write-lease coverage
asddongmen Aug 31, 2026
36e0fcf
writelease: notify waiters only when writable
asddongmen Aug 31, 2026
ae67aad
mysql: avoid holding connections while waiting for lease
asddongmen Aug 31, 2026
2c7d3e1
coordinator: retry witnesses before write lease expires
asddongmen Aug 31, 2026
483e181
coordinator,mysql: address capture write lease review findings
asddongmen Aug 31, 2026
5ba207f
writelease: add optional transport admission helpers
asddongmen Aug 31, 2026
57e018b
sink: gate Kafka writes at producer boundary
asddongmen Aug 31, 2026
1a70552
sink: gate Pulsar writes at producer boundary
asddongmen Aug 31, 2026
90d6646
sink: gate storage writes at publication boundary
asddongmen Aug 31, 2026
5f5d4fc
redo: gate persistence at publication boundary
asddongmen Aug 31, 2026
9dcf3c9
sink: require transport write-gate admission
asddongmen Aug 31, 2026
e4b59cd
Merge remote-tracking branch 'upstream/master' into 0826-capture-writ…
asddongmen Sep 1, 2026
ddacb96
docs: add capture write lease design
asddongmen Sep 1, 2026
dcfe564
docs: remove capture write lease PNGs
asddongmen Sep 1, 2026
411f58b
coordinator,tests: make write lease CI deterministic
asddongmen Sep 1, 2026
f74e149
tests: expect capture exit after PD loss
asddongmen Sep 1, 2026
b401472
tests: expect capture exit in synced status case
asddongmen Sep 1, 2026
7d87486
maintainer,tests: address write lease review comments
asddongmen Sep 2, 2026
fddb99e
coordinator,redo,tests: address write lease review
asddongmen Sep 3, 2026
23fcd27
common,server: fix unit test failures
asddongmen Sep 4, 2026
139844d
tests: reduce write lease YCSB workload
asddongmen Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions coordinator/capture_write_lease.go
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) {
Comment thread
asddongmen marked this conversation as resolved.
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
}
}
Loading
Loading