github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/kv/kvnemesis/validator.go (about) 1 // Copyright 2020 The Cockroach Authors. 2 // 3 // Use of this software is governed by the Business Source License 4 // included in the file licenses/BSL.txt. 5 // 6 // As of the Change Date specified in that file, in accordance with 7 // the Business Source License, use of this software will be governed 8 // by the Apache License, Version 2.0, included in the file 9 // licenses/APL.txt. 10 11 package kvnemesis 12 13 import ( 14 "context" 15 "fmt" 16 "regexp" 17 "sort" 18 "strings" 19 20 "github.com/cockroachdb/cockroach/pkg/roachpb" 21 "github.com/cockroachdb/cockroach/pkg/storage" 22 "github.com/cockroachdb/cockroach/pkg/util/hlc" 23 "github.com/cockroachdb/cockroach/pkg/util/uuid" 24 "github.com/cockroachdb/errors" 25 "github.com/cockroachdb/pebble" 26 ) 27 28 // Validate checks for violations of our kv api guarantees. The Steps must all 29 // have been applied and the kvs the result of those applications. 30 // 31 // For transactions, it is verified that all of their writes are present if and 32 // only if the transaction committed (which is inferred from the KV data on 33 // ambiguous results). Non-transactional read/write operations are treated as 34 // though they had been wrapped in a transaction and are verified accordingly. 35 // 36 // TODO(dan): Verify that the results of reads match the data visible at the 37 // commit timestamp. We should be able to construct a validity timespan for each 38 // read in the transaction and fail if there isn't some timestamp that overlaps 39 // all of them. 40 // 41 // TODO(dan): Verify that there is no causality inversion between steps. That 42 // is, if transactions corresponding to two steps are sequential (i.e. 43 // txn1CommitStep.After < txn2BeginStep.Before) then the commit timestamps need 44 // to reflect this ordering. 45 // 46 // TODO(dan): Consider changing all of this validation to be based on the commit 47 // timestamp as given back by kv.Txn. This doesn't currently work for 48 // nontransactional read-only ops (i.e. single read or batch of only reads) but 49 // that could be fixed by altering the API to communicating the timestamp back. 50 // 51 // Splits and merges are not verified for anything other than that they did not 52 // return an error. 53 func Validate(steps []Step, kvs *Engine) []error { 54 v, err := makeValidator(kvs) 55 if err != nil { 56 return []error{err} 57 } 58 59 // The validator works via AOST-style queries over the kvs emitted by 60 // RangeFeed. This means it can process steps in any order *except* that it 61 // needs to see all txn usage in order. Generator currently only emits 62 // ClosureTxnOperations, so it currently doesn't matter which order we process 63 // these. 64 // 65 // Originally there were separate operations for Begin/Use/Commit/Rollback 66 // Txn. If we add something like this back in (and I would like to), sorting 67 // by `After` timestamp is sufficient to get us the necessary ordering. This 68 // is because txns cannot be used concurrently, so none of the (Begin,After) 69 // timespans for a given transaction can overlap. 70 sort.Slice(steps, func(i, j int) bool { return steps[i].After.Less(steps[j].After) }) 71 for _, s := range steps { 72 v.processOp(nil /* txnID */, s.Op) 73 } 74 75 var extraKVs []observedOp 76 for _, kv := range v.kvByValue { 77 kv := &observedWrite{ 78 Key: kv.Key.Key, 79 Value: roachpb.Value{RawBytes: kv.Value}, 80 Timestamp: kv.Key.Timestamp, 81 Materialized: true, 82 } 83 extraKVs = append(extraKVs, kv) 84 } 85 if len(extraKVs) > 0 { 86 err := errors.Errorf(`extra writes: %s`, printObserved(extraKVs...)) 87 v.failures = append(v.failures, err) 88 } 89 90 return v.failures 91 } 92 93 // timeSpan represents a range of time with an inclusive start and an exclusive 94 // end. 95 type timeSpan struct { 96 Start, End hlc.Timestamp 97 } 98 99 func (ts timeSpan) Intersect(o timeSpan) timeSpan { 100 i := ts 101 if i.Start.Less(o.Start) { 102 i.Start = o.Start 103 } 104 if o.End.Less(i.End) { 105 i.End = o.End 106 } 107 return i 108 } 109 110 func (ts timeSpan) IsEmpty() bool { 111 return !ts.Start.Less(ts.End) 112 } 113 114 // observedOp is the unification of an externally observed KV read or write. 115 // Validator collects these grouped by txn, then computes the time window that 116 // it would have been valid to observe this read or write, then asserts that all 117 // operations in a transaction have time at which they all overlap. (For any 118 // transaction containing a write, this will be a single time, but for read-only 119 // transactions it will usually be a range of times.) 120 type observedOp interface { 121 observedMarker() 122 } 123 124 type observedWrite struct { 125 Key roachpb.Key 126 Value roachpb.Value 127 // Timestamp will only be filled if Materialized is true. 128 Timestamp hlc.Timestamp 129 Materialized bool 130 } 131 132 func (*observedWrite) observedMarker() {} 133 134 type observedRead struct { 135 Key roachpb.Key 136 Value roachpb.Value 137 Valid timeSpan 138 } 139 140 func (*observedRead) observedMarker() {} 141 142 type validator struct { 143 kvs *Engine 144 observedOpsByTxn map[string][]observedOp 145 146 // NB: The Generator carefully ensures that each value written is unique 147 // globally over a run, so there's a 1:1 relationship between a value that was 148 // written and the operation that wrote it. 149 kvByValue map[string]storage.MVCCKeyValue 150 151 failures []error 152 } 153 154 func makeValidator(kvs *Engine) (*validator, error) { 155 kvByValue := make(map[string]storage.MVCCKeyValue) 156 var err error 157 kvs.Iterate(func(key storage.MVCCKey, value []byte, iterErr error) { 158 if iterErr != nil { 159 err = errors.CombineErrors(err, iterErr) 160 return 161 } 162 v := roachpb.Value{RawBytes: value} 163 if v.GetTag() != roachpb.ValueType_UNKNOWN { 164 valueStr := mustGetStringValue(value) 165 if existing, ok := kvByValue[valueStr]; ok { 166 // TODO(dan): This may be too strict. Some operations (db.Run on a 167 // Batch) seem to be double-committing. See #46374. 168 panic(errors.AssertionFailedf( 169 `invariant violation: value %s was written by two operations %s and %s`, 170 valueStr, existing.Key, key)) 171 } 172 // NB: The Generator carefully ensures that each value written is unique 173 // globally over a run, so there's a 1:1 relationship between a value that 174 // was written and the operation that wrote it. 175 kvByValue[valueStr] = storage.MVCCKeyValue{Key: key, Value: value} 176 } 177 }) 178 if err != nil { 179 return nil, err 180 } 181 182 return &validator{ 183 kvs: kvs, 184 kvByValue: kvByValue, 185 observedOpsByTxn: make(map[string][]observedOp), 186 }, nil 187 } 188 189 func (v *validator) processOp(txnID *string, op Operation) { 190 switch t := op.GetValue().(type) { 191 case *GetOperation: 192 v.failIfError(op, t.Result) 193 if txnID == nil { 194 v.checkAtomic(`get`, t.Result, op) 195 } else { 196 read := &observedRead{ 197 Key: t.Key, 198 Value: roachpb.Value{RawBytes: t.Result.Value}, 199 } 200 v.observedOpsByTxn[*txnID] = append(v.observedOpsByTxn[*txnID], read) 201 } 202 case *PutOperation: 203 if txnID == nil { 204 v.checkAtomic(`put`, t.Result, op) 205 } else { 206 // Accumulate all the writes for this transaction. 207 kv, ok := v.kvByValue[string(t.Value)] 208 delete(v.kvByValue, string(t.Value)) 209 write := &observedWrite{ 210 Key: t.Key, 211 Value: roachpb.MakeValueFromBytes(t.Value), 212 Materialized: ok, 213 } 214 if write.Materialized { 215 write.Timestamp = kv.Key.Timestamp 216 } 217 v.observedOpsByTxn[*txnID] = append(v.observedOpsByTxn[*txnID], write) 218 } 219 case *SplitOperation: 220 v.failIfError(op, t.Result) 221 case *MergeOperation: 222 if resultIsError(t.Result, `cannot merge final range`) { 223 // Because of some non-determinism, it is not worth it (or maybe not 224 // possible) to prevent these usage errors. Additionally, I (dan) think 225 // this hints at some unnecessary friction in the AdminMerge api. There is 226 // a similar inconsistency when a race condition means that AdminMerge is 227 // called on something that is not a split point. I propose that the 228 // AdminMerge contract should be that it can be called on any key, split 229 // point or not, and after a successful operation, the guarantee is that 230 // there is no split at that key. #44378 231 // 232 // In the meantime, no-op. 233 } else if resultIsError(t.Result, `merge failed: unexpected value`) { 234 // TODO(dan): If this error is going to remain a part of the kv API, we 235 // should make it sniffable with errors.As. Currently this seems to be 236 // broken by wrapping it with `roachpb.NewErrorf("merge failed: %s", 237 // err)`. 238 // 239 // However, I think the right thing to do is sniff this inside the 240 // AdminMerge code and retry so the client never sees it. In the meantime, 241 // no-op. #44377 242 } else if resultIsError(t.Result, `merge failed: cannot merge range with non-voter replicas`) { 243 // This operation executed concurrently with one that was changing 244 // replicas. 245 } else if resultIsError(t.Result, `merge failed: ranges not collocated`) { 246 // A merge requires that the two ranges have replicas on the same nodes, 247 // but Generator intentiontally does not try to avoid this so that this 248 // edge case is exercised. 249 } else if resultIsError(t.Result, `merge failed: waiting for all left-hand replicas to initialize`) { 250 // Probably should be transparently retried. 251 } else if resultIsError(t.Result, `merge failed: waiting for all right-hand replicas to catch up`) { 252 // Probably should be transparently retried. 253 } else if resultIsError(t.Result, `merge failed: non-deletion intent on local range descriptor`) { 254 // Probably should be transparently retried. 255 } else if resultIsError(t.Result, `merge failed: range missing intent on its local descriptor`) { 256 // Probably should be transparently retried. 257 } else { 258 v.failIfError(op, t.Result) 259 } 260 case *ChangeReplicasOperation: 261 if resultIsError(t.Result, `unable to add replica .* which is already present in`) { 262 // Generator created this operations based on data about a range's 263 // replicas that is now stale (because it raced with some other operation 264 // created by that Generator): a replica is being added and in the 265 // meantime, some other operation added the same replica. 266 } else if resultIsError(t.Result, `unable to add replica .* which is already present as a learner`) { 267 // Generator created this operations based on data about a range's 268 // replicas that is now stale (because it raced with some other operation 269 // created by that Generator): a replica is being added and in the 270 // meantime, some other operation started (but did not finish) adding the 271 // same replica. 272 } else if resultIsError(t.Result, `descriptor changed`) { 273 // Race between two operations being executed concurrently. Applier grabs 274 // a range descriptor and then calls AdminChangeReplicas with it, but the 275 // descriptor is changed by some other operation in between. 276 } else if resultIsError(t.Result, `received invalid ChangeReplicasTrigger .* to remove self \(leaseholder\)`) { 277 // Removing the leaseholder is invalid for technical reasons, but 278 // Generator intentiontally does not try to avoid this so that this edge 279 // case is exercised. 280 } else if resultIsError(t.Result, `removing .* which is not in`) { 281 // Generator created this operations based on data about a range's 282 // replicas that is now stale (because it raced with some other operation 283 // created by that Generator): a replica is being removed and in the 284 // meantime, some other operation removed the same replica. 285 } else if resultIsError(t.Result, `remote failed to apply snapshot for reason failed to apply snapshot: raft group deleted`) { 286 // Probably should be transparently retried. 287 } else if resultIsError(t.Result, `cannot apply snapshot: snapshot intersects existing range`) { 288 // Probably should be transparently retried. 289 } else if resultIsError(t.Result, `snapshot of type LEARNER was sent to .* which did not contain it as a replica`) { 290 // Probably should be transparently retried. 291 } else { 292 v.failIfError(op, t.Result) 293 } 294 case *BatchOperation: 295 if !resultIsRetryable(t.Result) { 296 v.failIfError(op, t.Result) 297 if txnID == nil { 298 v.checkAtomic(`batch`, t.Result, t.Ops...) 299 } else { 300 for _, op := range t.Ops { 301 v.processOp(txnID, op) 302 } 303 } 304 } 305 case *ClosureTxnOperation: 306 ops := t.Ops 307 if t.CommitInBatch != nil { 308 ops = append(ops, t.CommitInBatch.Ops...) 309 } 310 v.checkAtomic(`txn`, t.Result, ops...) 311 default: 312 panic(errors.AssertionFailedf(`unknown operation type: %T %v`, t, t)) 313 } 314 } 315 316 func (v *validator) checkAtomic(atomicType string, result Result, ops ...Operation) { 317 fakeTxnID := uuid.MakeV4().String() 318 for _, op := range ops { 319 v.processOp(&fakeTxnID, op) 320 } 321 txnObservations := v.observedOpsByTxn[fakeTxnID] 322 delete(v.observedOpsByTxn, fakeTxnID) 323 if result.Type == ResultType_NoError { 324 v.checkCommittedTxn(`committed `+atomicType, txnObservations) 325 } else if resultIsAmbiguous(result) { 326 v.checkAmbiguousTxn(`ambiguous `+atomicType, txnObservations) 327 } else { 328 v.checkUncommittedTxn(`uncommitted `+atomicType, txnObservations) 329 } 330 } 331 332 func (v *validator) checkCommittedTxn(atomicType string, txnObservations []observedOp) { 333 // The following works by verifying that there is at least one time at which 334 // it was valid to see all the reads and writes that we saw in this 335 // transaction. 336 // 337 // Concretely a transaction: 338 // - Write k1@t2 -> v1 339 // - Read k2 -> v2 340 // 341 // And what was present in KV after this and some other transactions: 342 // - k1@t2, v1 343 // - k1@t3, v3 344 // - k2@t1, v2 345 // - k2@t3, v4 346 // 347 // Each of the operations in the transaction, if taken individually, has some 348 // window at which it was valid. The Write was only valid for a commit exactly 349 // at t2: [t2,t2). This is because each Write's mvcc timestamp is the 350 // timestamp of the txn commit. The Read would have been valid for [t1,t3) 351 // because v2 was written at t1 and overwritten at t3. 352 // 353 // As long as these time spans overlap, we're good. However, if another write 354 // had a timestamp of t3, then there is no timestamp at which the transaction 355 // could have committed, which is a violation of our consistency guarantees. 356 // Similarly if there was some read that was only valid from [t1,t2). 357 // 358 // Listen up, this is where it gets tricky. Within a transaction, if the same 359 // key is written more than once, only the last one will ever be materialized 360 // in KV (and be sent out over the RangeFeed). However, any reads in between 361 // will see the overwritten values. This means that each transaction needs its 362 // own view of KV when determining what is and is not valid. 363 // 364 // Concretely: 365 // - Read k -> <nil> 366 // - Write k -> v1 367 // - Read k -> v1 368 // - Write k -> v2 369 // - Read k -> v2 370 // 371 // This is okay, but if we only use the writes that come out of RangeFeed to 372 // compute our read validities, then there would be no time at which v1 could 373 // have been read. So, we have to "layer" the k -> v1 write on top of our 374 // RangeFeed output. At the same time, it would not have been valid for the 375 // first or second read to see v2 because when they ran that value hadn't 376 // been written yet. 377 // 378 // So, what we do to compute the read validity time windows is first hide all 379 // the writes the transaction eventually did in some "view". Then step through 380 // it, un-hiding each of them as we encounter each write, and using the 381 // current state of the view as we encounter each read. Luckily this is easy 382 // to do by with a pebble.Batch "view". 383 batch := v.kvs.kvs.NewIndexedBatch() 384 defer func() { _ = batch.Close() }() 385 386 // If the same key is written multiple times in a transaction, only the last 387 // one makes it to kv. 388 lastWriteIdxByKey := make(map[string]int, len(txnObservations)) 389 for idx := len(txnObservations) - 1; idx >= 0; idx-- { 390 observation := txnObservations[idx] 391 switch o := observation.(type) { 392 case *observedWrite: 393 if _, ok := lastWriteIdxByKey[string(o.Key)]; !ok { 394 lastWriteIdxByKey[string(o.Key)] = idx 395 } 396 mvccKey := storage.MVCCKey{Key: o.Key, Timestamp: o.Timestamp} 397 if err := batch.Delete(storage.EncodeKey(mvccKey), nil); err != nil { 398 panic(err) 399 } 400 } 401 } 402 403 // Check if any key that was written twice in the txn had the overwritten 404 // writes materialize in kv. Also fill in all the read timestamps first so 405 // they show up in the failure message. 406 var failure string 407 for idx, observation := range txnObservations { 408 switch o := observation.(type) { 409 case *observedWrite: 410 if lastWriteIdx := lastWriteIdxByKey[string(o.Key)]; idx == lastWriteIdx { 411 // The last write of a given key in the txn wins and should have made it 412 // to kv. 413 mvccKey := storage.MVCCKey{Key: o.Key, Timestamp: o.Timestamp} 414 if err := batch.Set(storage.EncodeKey(mvccKey), o.Value.RawBytes, nil); err != nil { 415 panic(err) 416 } 417 } else { 418 if o.Materialized { 419 failure = `committed txn overwritten key had write` 420 } 421 // This write was never materialized in KV because the key got 422 // overwritten later in the txn. But reads in the txn could have seen 423 // it, so we put in the batch being maintained for validReadTime using 424 // the timestamp of the write for this key that eventually "won". 425 mvccKey := storage.MVCCKey{ 426 Key: o.Key, 427 Timestamp: txnObservations[lastWriteIdx].(*observedWrite).Timestamp, 428 } 429 if err := batch.Set(storage.EncodeKey(mvccKey), o.Value.RawBytes, nil); err != nil { 430 panic(err) 431 } 432 } 433 case *observedRead: 434 o.Valid = validReadTime(batch, o.Key, o.Value.RawBytes) 435 default: 436 panic(errors.AssertionFailedf(`unknown observedOp: %T %s`, observation, observation)) 437 } 438 } 439 440 valid := timeSpan{Start: hlc.MinTimestamp, End: hlc.MaxTimestamp} 441 for idx, observation := range txnObservations { 442 if failure != `` { 443 break 444 } 445 var opValid timeSpan 446 switch o := observation.(type) { 447 case *observedWrite: 448 isLastWriteForKey := idx == lastWriteIdxByKey[string(o.Key)] 449 if !isLastWriteForKey { 450 continue 451 } 452 if !o.Materialized { 453 failure = atomicType + ` missing write` 454 continue 455 } 456 opValid = timeSpan{Start: o.Timestamp, End: o.Timestamp.Next()} 457 case *observedRead: 458 opValid = o.Valid 459 default: 460 panic(errors.AssertionFailedf(`unknown observedOp: %T %s`, observation, observation)) 461 } 462 intersection := valid.Intersect(opValid) 463 if intersection.IsEmpty() { 464 failure = atomicType + ` non-atomic timestamps` 465 } 466 valid = intersection 467 } 468 469 if failure != `` { 470 err := errors.Errorf("%s: %s", failure, printObserved(txnObservations...)) 471 v.failures = append(v.failures, err) 472 } 473 } 474 475 func (v *validator) checkAmbiguousTxn(atomicType string, txnObservations []observedOp) { 476 var somethingCommitted bool 477 var hadWrite bool 478 for _, observation := range txnObservations { 479 switch o := observation.(type) { 480 case *observedWrite: 481 hadWrite = true 482 if o.Materialized { 483 somethingCommitted = true 484 break 485 } 486 } 487 } 488 if !hadWrite { 489 // TODO(dan): Is it possible to receive an ambiguous read-only txn? Assume 490 // committed for now because the committed case has assertions about reads 491 // but the uncommitted case doesn't and this seems to work. 492 v.checkCommittedTxn(atomicType, txnObservations) 493 } else if somethingCommitted { 494 v.checkCommittedTxn(atomicType, txnObservations) 495 } else { 496 v.checkUncommittedTxn(atomicType, txnObservations) 497 } 498 } 499 500 func (v *validator) checkUncommittedTxn(atomicType string, txnObservations []observedOp) { 501 var failure string 502 for _, observed := range txnObservations { 503 if failure != `` { 504 break 505 } 506 switch o := observed.(type) { 507 case *observedWrite: 508 if o.Materialized { 509 failure = atomicType + ` had writes` 510 } 511 case *observedRead: 512 // TODO(dan): Figure out what we can assert about reads in an uncommitted 513 // transaction. 514 default: 515 panic(errors.AssertionFailedf(`unknown observedOp: %T %s`, observed, observed)) 516 } 517 } 518 519 if failure != `` { 520 err := errors.Errorf("%s: %s", failure, printObserved(txnObservations...)) 521 v.failures = append(v.failures, err) 522 } 523 } 524 525 func (v *validator) failIfError(op Operation, r Result) { 526 switch r.Type { 527 case ResultType_Unknown: 528 err := errors.AssertionFailedf(`unknown result %s`, op) 529 v.failures = append(v.failures, err) 530 case ResultType_Error: 531 ctx := context.Background() 532 err := errors.DecodeError(ctx, *r.Err) 533 err = errors.Wrapf(err, `error applying %s`, op) 534 v.failures = append(v.failures, err) 535 } 536 } 537 538 // TODO(dan): Checking errors using string containment is fragile at best and a 539 // security issue at worst. Unfortunately, some errors that currently make it 540 // out of our kv apis are created with `errors.New` and so do not have types 541 // that can be sniffed. Some of these may be removed or handled differently but 542 // the rest should graduate to documented parts of the public api. Remove this 543 // once it happens. 544 func resultIsError(r Result, msgRE string) bool { 545 if r.Type != ResultType_Error { 546 return false 547 } 548 ctx := context.Background() 549 err := errors.DecodeError(ctx, *r.Err) 550 return regexp.MustCompile(msgRE).MatchString(err.Error()) 551 } 552 553 func resultIsRetryable(r Result) bool { 554 if r.Type != ResultType_Error { 555 return false 556 } 557 ctx := context.Background() 558 err := errors.DecodeError(ctx, *r.Err) 559 return errors.HasInterface(err, (*roachpb.ClientVisibleRetryError)(nil)) 560 } 561 562 func resultIsAmbiguous(r Result) bool { 563 if r.Type != ResultType_Error { 564 return false 565 } 566 ctx := context.Background() 567 err := errors.DecodeError(ctx, *r.Err) 568 return errors.HasInterface(err, (*roachpb.ClientVisibleAmbiguousError)(nil)) 569 } 570 571 func mustGetStringValue(value []byte) string { 572 if len(value) == 0 { 573 return `<nil>` 574 } 575 v, err := roachpb.Value{RawBytes: value}.GetBytes() 576 if err != nil { 577 panic(errors.Wrapf(err, "decoding %x", value)) 578 } 579 return string(v) 580 } 581 582 func validReadTime(b *pebble.Batch, key roachpb.Key, value []byte) timeSpan { 583 var validTime []timeSpan 584 end := hlc.MaxTimestamp 585 586 iter := b.NewIter(nil) 587 defer func() { _ = iter.Close() }() 588 iter.SeekGE(storage.EncodeKey(storage.MVCCKey{Key: key})) 589 for ; iter.Valid(); iter.Next() { 590 mvccKey, err := storage.DecodeMVCCKey(iter.Key()) 591 if err != nil { 592 panic(err) 593 } 594 if !mvccKey.Key.Equal(key) { 595 break 596 } 597 if mustGetStringValue(iter.Value()) == mustGetStringValue(value) { 598 validTime = append(validTime, timeSpan{Start: mvccKey.Timestamp, End: end}) 599 } 600 end = mvccKey.Timestamp 601 } 602 if len(value) == 0 { 603 validTime = append(validTime, timeSpan{Start: hlc.MinTimestamp, End: end}) 604 } 605 606 if len(validTime) == 0 { 607 return timeSpan{} 608 } else if len(validTime) == 1 { 609 return validTime[0] 610 } else { 611 // TODO(dan): Until we add deletes, the "only write each value once" 612 // property of the generator means that we have a 1:1 mapping between some 613 // `(key, possibly-nil-value)` observation and a time span in which it was 614 // valid. Once we add deletes, there will be multiple disjoint spans for the 615 // `(key, nil)` case. 616 panic(`unreachable`) 617 } 618 } 619 620 func printObserved(observedOps ...observedOp) string { 621 var buf strings.Builder 622 for _, observed := range observedOps { 623 if buf.Len() > 0 { 624 buf.WriteString(" ") 625 } 626 switch o := observed.(type) { 627 case *observedWrite: 628 ts := `missing` 629 if o.Materialized { 630 ts = o.Timestamp.String() 631 } 632 fmt.Fprintf(&buf, "[w]%s:%s->%s", o.Key, ts, mustGetStringValue(o.Value.RawBytes)) 633 case *observedRead: 634 var start string 635 if o.Valid.Start == hlc.MinTimestamp { 636 start = `<min>` 637 } else { 638 start = o.Valid.Start.String() 639 } 640 var end string 641 if o.Valid.End == hlc.MaxTimestamp { 642 end = `<max>` 643 } else { 644 end = o.Valid.End.String() 645 } 646 fmt.Fprintf(&buf, "[r]%s:[%s,%s)->%s", 647 o.Key, start, end, mustGetStringValue(o.Value.RawBytes)) 648 default: 649 panic(errors.AssertionFailedf(`unknown observedOp: %T %s`, observed, observed)) 650 } 651 } 652 return buf.String() 653 }