github.com/pingcap/tiflow@v0.0.0-20240520035814-5bf52d54e205/pkg/orchestrator/reactor_state.go (about) 1 // Copyright 2021 PingCAP, Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // See the License for the specific language governing permissions and 12 // limitations under the License. 13 14 package orchestrator 15 16 import ( 17 "reflect" 18 "time" 19 20 "github.com/goccy/go-json" 21 "github.com/pingcap/errors" 22 "github.com/pingcap/log" 23 "github.com/pingcap/tiflow/cdc/model" 24 cerrors "github.com/pingcap/tiflow/pkg/errors" 25 "github.com/pingcap/tiflow/pkg/etcd" 26 "github.com/pingcap/tiflow/pkg/orchestrator/util" 27 "go.uber.org/zap" 28 ) 29 30 const defaultCaptureRemoveTTL = 5 31 32 // GlobalReactorState represents a global state which stores all key-value pairs in ETCD 33 type GlobalReactorState struct { 34 ClusterID string 35 Role string 36 Owner map[string]struct{} 37 Captures map[model.CaptureID]*model.CaptureInfo 38 Upstreams map[model.UpstreamID]*model.UpstreamInfo 39 Changefeeds map[model.ChangeFeedID]*ChangefeedReactorState 40 pendingPatches [][]DataPatch 41 42 // onCaptureAdded and onCaptureRemoved are hook functions 43 // to be called when captures are added and removed. 44 onCaptureAdded func(captureID model.CaptureID, addr string) 45 onCaptureRemoved func(captureID model.CaptureID) 46 47 captureRemoveTTL int 48 toRemoveCaptures map[model.CaptureID]time.Time 49 } 50 51 // NewGlobalState creates a new global state. 52 func NewGlobalState(clusterID string, captureSessionTTL int) *GlobalReactorState { 53 captureRemoveTTL := captureSessionTTL / 2 54 if captureRemoveTTL < defaultCaptureRemoveTTL { 55 captureRemoveTTL = defaultCaptureRemoveTTL 56 } 57 return &GlobalReactorState{ 58 ClusterID: clusterID, 59 Owner: map[string]struct{}{}, 60 Captures: make(map[model.CaptureID]*model.CaptureInfo), 61 Upstreams: make(map[model.UpstreamID]*model.UpstreamInfo), 62 Changefeeds: make(map[model.ChangeFeedID]*ChangefeedReactorState), 63 captureRemoveTTL: captureRemoveTTL, 64 toRemoveCaptures: make(map[model.CaptureID]time.Time), 65 } 66 } 67 68 // NewGlobalStateForTest creates a new global state for test. 69 func NewGlobalStateForTest(clusterID string) *GlobalReactorState { 70 return NewGlobalState(clusterID, 0) 71 } 72 73 // UpdatePendingChange implements the ReactorState interface 74 func (s *GlobalReactorState) UpdatePendingChange() { 75 for c, t := range s.toRemoveCaptures { 76 if time.Since(t) >= time.Duration(s.captureRemoveTTL)*time.Second { 77 log.Info("remote capture offline", zap.Any("info", s.Captures[c]), zap.String("role", s.Role)) 78 delete(s.Captures, c) 79 if s.onCaptureRemoved != nil { 80 s.onCaptureRemoved(c) 81 } 82 delete(s.toRemoveCaptures, c) 83 } 84 } 85 } 86 87 // Update implements the ReactorState interface 88 func (s *GlobalReactorState) Update(key util.EtcdKey, value []byte, _ bool) error { 89 k := new(etcd.CDCKey) 90 err := k.Parse(s.ClusterID, key.String()) 91 if err != nil { 92 return errors.Trace(err) 93 } 94 95 switch k.Tp { 96 case etcd.CDCKeyTypeOwner: 97 if value != nil { 98 s.Owner[k.OwnerLeaseID] = struct{}{} 99 } else { 100 delete(s.Owner, k.OwnerLeaseID) 101 } 102 return nil 103 case etcd.CDCKeyTypeCapture: 104 if value == nil { 105 log.Info("remote capture offline detected", zap.Any("info", s.Captures[k.CaptureID]), zap.String("role", s.Role)) 106 s.toRemoveCaptures[k.CaptureID] = time.Now() 107 return nil 108 } 109 110 var newCaptureInfo model.CaptureInfo 111 err := newCaptureInfo.Unmarshal(value) 112 if err != nil { 113 return cerrors.ErrUnmarshalFailed.Wrap(err).GenWithStackByArgs() 114 } 115 116 log.Info("remote capture online", zap.Any("info", newCaptureInfo), zap.String("role", s.Role)) 117 if s.onCaptureAdded != nil { 118 s.onCaptureAdded(k.CaptureID, newCaptureInfo.AdvertiseAddr) 119 } 120 s.Captures[k.CaptureID] = &newCaptureInfo 121 case etcd.CDCKeyTypeChangefeedInfo, 122 etcd.CDCKeyTypeChangeFeedStatus, 123 etcd.CDCKeyTypeTaskPosition: 124 changefeedState, exist := s.Changefeeds[k.ChangefeedID] 125 if !exist { 126 if value == nil { 127 return nil 128 } 129 changefeedState = NewChangefeedReactorState(s.ClusterID, k.ChangefeedID) 130 s.Changefeeds[k.ChangefeedID] = changefeedState 131 } 132 if err := changefeedState.UpdateCDCKey(k, value); err != nil { 133 return errors.Trace(err) 134 } 135 if value == nil && !changefeedState.Exist() { 136 s.pendingPatches = append(s.pendingPatches, changefeedState.getPatches()) 137 delete(s.Changefeeds, k.ChangefeedID) 138 } 139 case etcd.CDCKeyTypeUpStream: 140 if value == nil { 141 log.Info("upstream is removed", 142 zap.Uint64("upstreamID", k.UpstreamID), 143 zap.Any("info", s.Upstreams[k.UpstreamID]), 144 zap.String("role", s.Role)) 145 delete(s.Upstreams, k.UpstreamID) 146 return nil 147 } 148 var newUpstreamInfo model.UpstreamInfo 149 err := newUpstreamInfo.Unmarshal(value) 150 if err != nil { 151 return cerrors.ErrUnmarshalFailed.Wrap(err).GenWithStackByArgs() 152 } 153 log.Info("new upstream is add", zap.Uint64("upstream", k.UpstreamID), 154 zap.Any("info", newUpstreamInfo), zap.String("role", s.Role)) 155 s.Upstreams[k.UpstreamID] = &newUpstreamInfo 156 case etcd.CDCKeyTypeMetaVersion: 157 default: 158 log.Warn("receive an unexpected etcd event", zap.String("key", key.String()), 159 zap.ByteString("value", value), zap.String("role", s.Role)) 160 } 161 return nil 162 } 163 164 // GetPatches implements the ReactorState interface 165 // Every []DataPatch slice in [][]DataPatch slice is the patches of a ChangefeedReactorState 166 func (s *GlobalReactorState) GetPatches() [][]DataPatch { 167 pendingPatches := s.pendingPatches 168 for _, changefeedState := range s.Changefeeds { 169 pendingPatches = append(pendingPatches, changefeedState.getPatches()) 170 } 171 s.pendingPatches = nil 172 return pendingPatches 173 } 174 175 // SetOnCaptureAdded registers a function that is called when a capture goes online. 176 func (s *GlobalReactorState) SetOnCaptureAdded(f func(captureID model.CaptureID, addr string)) { 177 s.onCaptureAdded = f 178 } 179 180 // SetOnCaptureRemoved registers a function that is called when a capture goes offline. 181 func (s *GlobalReactorState) SetOnCaptureRemoved(f func(captureID model.CaptureID)) { 182 s.onCaptureRemoved = f 183 } 184 185 // ChangefeedReactorState represents a changefeed state which stores all key-value pairs of a changefeed in ETCD 186 type ChangefeedReactorState struct { 187 ClusterID string 188 ID model.ChangeFeedID 189 Info *model.ChangeFeedInfo 190 Status *model.ChangeFeedStatus 191 TaskPositions map[model.CaptureID]*model.TaskPosition 192 193 pendingPatches []DataPatch 194 skipPatchesInThisTick bool 195 } 196 197 // NewChangefeedReactorState creates a new changefeed reactor state 198 func NewChangefeedReactorState(clusterID string, 199 id model.ChangeFeedID, 200 ) *ChangefeedReactorState { 201 return &ChangefeedReactorState{ 202 ClusterID: clusterID, 203 ID: id, 204 TaskPositions: make(map[model.CaptureID]*model.TaskPosition), 205 } 206 } 207 208 // GetID returns the changefeed ID. 209 func (s *ChangefeedReactorState) GetID() model.ChangeFeedID { 210 return s.ID 211 } 212 213 // GetChangefeedInfo returns the changefeed info. 214 func (s *ChangefeedReactorState) GetChangefeedInfo() *model.ChangeFeedInfo { 215 return s.Info 216 } 217 218 // GetChangefeedStatus returns the changefeed status. 219 func (s *ChangefeedReactorState) GetChangefeedStatus() *model.ChangeFeedStatus { 220 return s.Status 221 } 222 223 // SetWarning sets the warning to changefeed 224 func (s *ChangefeedReactorState) SetWarning(lastError *model.RunningError) { 225 s.PatchInfo(func(info *model.ChangeFeedInfo) (*model.ChangeFeedInfo, bool, error) { 226 if info == nil { 227 return nil, false, nil 228 } 229 info.Warning = lastError 230 return info, true, nil 231 }) 232 } 233 234 // SetError sets the error to changefeed 235 func (s *ChangefeedReactorState) SetError(lastError *model.RunningError) { 236 s.PatchInfo(func(info *model.ChangeFeedInfo) (*model.ChangeFeedInfo, bool, error) { 237 if info == nil { 238 return nil, false, nil 239 } 240 info.Error = lastError 241 return info, true, nil 242 }) 243 } 244 245 // RemoveChangefeed removes the changefeed and clean the information and status. 246 func (s *ChangefeedReactorState) RemoveChangefeed() { 247 // remove info 248 s.PatchInfo(func(info *model.ChangeFeedInfo) ( 249 *model.ChangeFeedInfo, bool, error, 250 ) { 251 return nil, true, nil 252 }) 253 // remove changefeedStatus 254 s.PatchStatus( 255 func(status *model.ChangeFeedStatus) ( 256 *model.ChangeFeedStatus, bool, error, 257 ) { 258 return nil, true, nil 259 }) 260 } 261 262 // ResumeChnagefeed resumes the changefeed and set the checkpoint ts. 263 func (s *ChangefeedReactorState) ResumeChnagefeed(overwriteCheckpointTs uint64) { 264 s.PatchInfo(func(info *model.ChangeFeedInfo) (*model.ChangeFeedInfo, bool, error) { 265 changed := false 266 if info == nil { 267 return nil, changed, nil 268 } 269 if overwriteCheckpointTs > 0 { 270 info.StartTs = overwriteCheckpointTs 271 changed = true 272 } 273 if info.Error != nil { 274 info.Error = nil 275 changed = true 276 } 277 return info, changed, nil 278 }) 279 280 s.PatchStatus(func(status *model.ChangeFeedStatus) ( 281 *model.ChangeFeedStatus, bool, error, 282 ) { 283 if overwriteCheckpointTs > 0 { 284 oldCheckpointTs := status.CheckpointTs 285 status = &model.ChangeFeedStatus{ 286 CheckpointTs: overwriteCheckpointTs, 287 MinTableBarrierTs: overwriteCheckpointTs, 288 AdminJobType: model.AdminNone, 289 } 290 log.Info("overwriting the tableCheckpoint ts", 291 zap.String("namespace", s.ID.Namespace), 292 zap.String("changefeed", s.ID.ID), 293 zap.Any("oldCheckpointTs", oldCheckpointTs), 294 zap.Any("newCheckpointTs", status.CheckpointTs), 295 ) 296 return status, true, nil 297 } 298 return status, false, nil 299 }) 300 } 301 302 // TakeProcessorErrors reuturns the error of the changefeed and clean the error. 303 func (s *ChangefeedReactorState) TakeProcessorErrors() []*model.RunningError { 304 var runningErrors map[string]*model.RunningError 305 for captureID, position := range s.TaskPositions { 306 if position.Error != nil { 307 if runningErrors == nil { 308 runningErrors = make(map[string]*model.RunningError) 309 } 310 runningErrors[position.Error.Code] = position.Error 311 log.Error("processor reports an error", 312 zap.String("namespace", s.ID.Namespace), 313 zap.String("changefeed", s.ID.ID), 314 zap.String("captureID", captureID), 315 zap.Any("error", position.Error)) 316 s.PatchTaskPosition(captureID, func(position *model.TaskPosition) (*model.TaskPosition, bool, error) { 317 if position == nil { 318 return nil, false, nil 319 } 320 position.Error = nil 321 return position, true, nil 322 }) 323 } 324 } 325 if runningErrors == nil { 326 return nil 327 } 328 result := make([]*model.RunningError, 0, len(runningErrors)) 329 for _, err := range runningErrors { 330 result = append(result, err) 331 } 332 return result 333 } 334 335 // TakeProcessorWarnings reuturns the warning of the changefeed and clean the warning. 336 func (s *ChangefeedReactorState) TakeProcessorWarnings() []*model.RunningError { 337 var runningWarnings map[string]*model.RunningError 338 for captureID, position := range s.TaskPositions { 339 if position.Warning != nil { 340 if runningWarnings == nil { 341 runningWarnings = make(map[string]*model.RunningError) 342 } 343 runningWarnings[position.Warning.Code] = position.Warning 344 log.Warn("processor reports a warning", 345 zap.String("namespace", s.ID.Namespace), 346 zap.String("changefeed", s.ID.ID), 347 zap.String("captureID", captureID), 348 zap.Any("warning", position.Warning)) 349 s.PatchTaskPosition(captureID, func(position *model.TaskPosition) (*model.TaskPosition, bool, error) { 350 if position == nil { 351 return nil, false, nil 352 } 353 // set Warning to nil after it has been handled 354 position.Warning = nil 355 return position, true, nil 356 }) 357 } 358 } 359 if runningWarnings == nil { 360 return nil 361 } 362 result := make([]*model.RunningError, 0, len(runningWarnings)) 363 for _, err := range runningWarnings { 364 result = append(result, err) 365 } 366 return result 367 } 368 369 // CleanUpTaskPositions removes the task positions of the changefeed. 370 func (s *ChangefeedReactorState) CleanUpTaskPositions() { 371 for captureID := range s.TaskPositions { 372 s.PatchTaskPosition(captureID, func(position *model.TaskPosition) (*model.TaskPosition, bool, error) { 373 return nil, true, nil 374 }) 375 } 376 } 377 378 // UpdateChangefeedState returns the task status of the changefeed. 379 func (s *ChangefeedReactorState) UpdateChangefeedState(feedState model.FeedState, 380 adminJobType model.AdminJobType, 381 epoch uint64, 382 ) { 383 s.PatchStatus(func(status *model.ChangeFeedStatus) (*model.ChangeFeedStatus, bool, error) { 384 if status == nil { 385 return status, false, nil 386 } 387 if status.AdminJobType != adminJobType { 388 status.AdminJobType = adminJobType 389 return status, true, nil 390 } 391 return status, false, nil 392 }) 393 s.PatchInfo(func(info *model.ChangeFeedInfo) (*model.ChangeFeedInfo, bool, error) { 394 changed := false 395 if info == nil { 396 return nil, changed, nil 397 } 398 if info.State != feedState { 399 info.State = feedState 400 changed = true 401 } 402 if info.AdminJobType != adminJobType { 403 info.AdminJobType = adminJobType 404 changed = true 405 406 if epoch > 0 { 407 previous := info.Epoch 408 info.Epoch = epoch 409 log.Info("update changefeed epoch", 410 zap.String("namespace", s.ID.Namespace), 411 zap.String("changefeed", s.ID.ID), 412 zap.Uint64("perviousEpoch", previous), 413 zap.Uint64("currentEpoch", info.Epoch)) 414 } 415 } 416 return info, changed, nil 417 }) 418 } 419 420 // UpdatePendingChange implements the ReactorState interface 421 func (s *ChangefeedReactorState) UpdatePendingChange() { 422 } 423 424 // Update implements the ReactorState interface 425 func (s *ChangefeedReactorState) Update(key util.EtcdKey, value []byte, _ bool) error { 426 k := new(etcd.CDCKey) 427 if err := k.Parse(s.ClusterID, key.String()); err != nil { 428 return errors.Trace(err) 429 } 430 if err := s.UpdateCDCKey(k, value); err != nil { 431 log.Error("failed to update status", zap.String("key", key.String()), zap.ByteString("value", value)) 432 return errors.Trace(err) 433 } 434 return nil 435 } 436 437 // UpdateCDCKey updates the state by a parsed etcd key 438 func (s *ChangefeedReactorState) UpdateCDCKey(key *etcd.CDCKey, value []byte) error { 439 var e interface{} 440 switch key.Tp { 441 case etcd.CDCKeyTypeChangefeedInfo: 442 if key.ChangefeedID != s.ID { 443 return nil 444 } 445 if value == nil { 446 s.Info = nil 447 return nil 448 } 449 s.Info = new(model.ChangeFeedInfo) 450 e = s.Info 451 case etcd.CDCKeyTypeChangeFeedStatus: 452 if key.ChangefeedID != s.ID { 453 return nil 454 } 455 if value == nil { 456 s.Status = nil 457 return nil 458 } 459 s.Status = new(model.ChangeFeedStatus) 460 e = s.Status 461 case etcd.CDCKeyTypeTaskPosition: 462 if key.ChangefeedID != s.ID { 463 return nil 464 } 465 if value == nil { 466 delete(s.TaskPositions, key.CaptureID) 467 return nil 468 } 469 position := new(model.TaskPosition) 470 s.TaskPositions[key.CaptureID] = position 471 e = position 472 default: 473 return nil 474 } 475 if err := json.Unmarshal(value, e); err != nil { 476 return errors.Trace(err) 477 } 478 if key.Tp == etcd.CDCKeyTypeChangefeedInfo { 479 s.Info.VerifyAndComplete() 480 } 481 return nil 482 } 483 484 // Exist returns false if all keys of this changefeed in ETCD is not exist 485 func (s *ChangefeedReactorState) Exist() bool { 486 return s.Info != nil || s.Status != nil || len(s.TaskPositions) != 0 487 } 488 489 // Active return true if the changefeed is ready to be processed 490 func (s *ChangefeedReactorState) Active(captureID model.CaptureID) bool { 491 return s.Info != nil && s.Status != nil && s.Status.AdminJobType == model.AdminNone 492 } 493 494 // GetPatches implements the ReactorState interface 495 func (s *ChangefeedReactorState) GetPatches() [][]DataPatch { 496 return [][]DataPatch{s.getPatches()} 497 } 498 499 func (s *ChangefeedReactorState) getPatches() []DataPatch { 500 pendingPatches := s.pendingPatches 501 s.pendingPatches = nil 502 return pendingPatches 503 } 504 505 // CheckCaptureAlive checks if the capture is alive, if the capture offline, 506 // the etcd worker will exit and throw the ErrLeaseExpired error. 507 func (s *ChangefeedReactorState) CheckCaptureAlive(captureID model.CaptureID) { 508 k := etcd.CDCKey{ 509 ClusterID: s.ClusterID, 510 Tp: etcd.CDCKeyTypeCapture, 511 CaptureID: captureID, 512 } 513 key := k.String() 514 patch := &SingleDataPatch{ 515 Key: util.NewEtcdKey(key), 516 Func: func(v []byte) ([]byte, bool, error) { 517 // If v is empty, it means that the key-value pair of capture info is not exist. 518 // The key-value pair of capture info is written with lease, 519 // so if the capture info is not exist, the lease is expired 520 if len(v) == 0 { 521 return v, false, cerrors.ErrLeaseExpired.GenWithStackByArgs() 522 } 523 return v, false, nil 524 }, 525 } 526 s.pendingPatches = append(s.pendingPatches, patch) 527 } 528 529 // CheckChangefeedNormal checks if the changefeed state is runnable, 530 // if the changefeed status is not runnable, the etcd worker will skip all patch of this tick 531 // the processor should call this function every tick to make sure the changefeed is runnable 532 func (s *ChangefeedReactorState) CheckChangefeedNormal() { 533 s.skipPatchesInThisTick = false 534 s.PatchInfo(func(info *model.ChangeFeedInfo) (*model.ChangeFeedInfo, bool, error) { 535 if info == nil || info.AdminJobType.IsStopState() { 536 s.skipPatchesInThisTick = true 537 return info, false, cerrors.ErrEtcdTryAgain.GenWithStackByArgs() 538 } 539 return info, false, nil 540 }) 541 s.PatchStatus(func(status *model.ChangeFeedStatus) (*model.ChangeFeedStatus, bool, error) { 542 if status == nil { 543 return status, false, nil 544 } 545 if status.AdminJobType.IsStopState() { 546 s.skipPatchesInThisTick = true 547 return status, false, cerrors.ErrEtcdTryAgain.GenWithStackByArgs() 548 } 549 return status, false, nil 550 }) 551 } 552 553 // PatchInfo appends a DataPatch which can modify the ChangeFeedInfo 554 func (s *ChangefeedReactorState) PatchInfo(fn func(*model.ChangeFeedInfo) (*model.ChangeFeedInfo, bool, error)) { 555 key := &etcd.CDCKey{ 556 ClusterID: s.ClusterID, 557 Tp: etcd.CDCKeyTypeChangefeedInfo, 558 ChangefeedID: s.ID, 559 } 560 s.patchAny(key.String(), changefeedInfoTPI, func(e interface{}) (interface{}, bool, error) { 561 // e == nil means that the key is not exist before this patch 562 if e == nil { 563 return fn(nil) 564 } 565 return fn(e.(*model.ChangeFeedInfo)) 566 }) 567 } 568 569 // PatchStatus appends a DataPatch which can modify the ChangeFeedStatus 570 func (s *ChangefeedReactorState) PatchStatus(fn func(*model.ChangeFeedStatus) (*model.ChangeFeedStatus, bool, error)) { 571 key := &etcd.CDCKey{ 572 ClusterID: s.ClusterID, 573 Tp: etcd.CDCKeyTypeChangeFeedStatus, 574 ChangefeedID: s.ID, 575 } 576 s.patchAny(key.String(), changefeedStatusTPI, func(e interface{}) (interface{}, bool, error) { 577 // e == nil means that the key is not exist before this patch 578 if e == nil { 579 return fn(nil) 580 } 581 return fn(e.(*model.ChangeFeedStatus)) 582 }) 583 } 584 585 // PatchTaskPosition appends a DataPatch which can modify the TaskPosition of a specified capture 586 func (s *ChangefeedReactorState) PatchTaskPosition(captureID model.CaptureID, fn func(*model.TaskPosition) (*model.TaskPosition, bool, error)) { 587 key := &etcd.CDCKey{ 588 ClusterID: s.ClusterID, 589 Tp: etcd.CDCKeyTypeTaskPosition, 590 CaptureID: captureID, 591 ChangefeedID: s.ID, 592 } 593 s.patchAny(key.String(), taskPositionTPI, func(e interface{}) (interface{}, bool, error) { 594 // e == nil means that the key is not exist before this patch 595 if e == nil { 596 return fn(nil) 597 } 598 return fn(e.(*model.TaskPosition)) 599 }) 600 } 601 602 var ( 603 taskPositionTPI *model.TaskPosition 604 changefeedStatusTPI *model.ChangeFeedStatus 605 changefeedInfoTPI *model.ChangeFeedInfo 606 ) 607 608 func (s *ChangefeedReactorState) patchAny(key string, tpi interface{}, fn func(interface{}) (interface{}, bool, error)) { 609 patch := &SingleDataPatch{ 610 Key: util.NewEtcdKey(key), 611 Func: func(v []byte) ([]byte, bool, error) { 612 if s.skipPatchesInThisTick { 613 return v, false, cerrors.ErrEtcdIgnore.GenWithStackByArgs() 614 } 615 var e interface{} 616 if v != nil { 617 tp := reflect.TypeOf(tpi) 618 e = reflect.New(tp.Elem()).Interface() 619 err := json.Unmarshal(v, e) 620 if err != nil { 621 return nil, false, errors.Trace(err) 622 } 623 } 624 ne, changed, err := fn(e) 625 if err != nil { 626 return nil, false, errors.Trace(err) 627 } 628 if !changed { 629 return v, false, nil 630 } 631 if reflect.ValueOf(ne).IsNil() { 632 return nil, true, nil 633 } 634 nv, err := json.Marshal(ne) 635 if err != nil { 636 return nil, false, errors.Trace(err) 637 } 638 return nv, true, nil 639 }, 640 } 641 s.pendingPatches = append(s.pendingPatches, patch) 642 }