go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/changepoints/inputbuffer/input_buffer.go (about) 1 // Copyright 2023 The LUCI Authors. 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 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package inputbuffer handles the input buffer of change point analysis. 16 package inputbuffer 17 18 import ( 19 "bytes" 20 "encoding/binary" 21 "fmt" 22 "time" 23 24 "go.chromium.org/luci/common/errors" 25 26 "go.chromium.org/luci/analysis/internal/span" 27 ) 28 29 const ( 30 // The version of the encoding to encode the verdict history. 31 EncodingVersion = 2 32 // Capacity of the hot buffer, i.e. how many verdicts it can hold. 33 DefaultHotBufferCapacity = 100 34 // Capacity of the cold buffer, i.e. how many verdicts it can hold. 35 DefaultColdBufferCapacity = 2000 36 // VerdictsInsertedHint is the number of verdicts expected 37 // to be inserted in one usage of the input buffer. Buffers 38 // will be allocated assuming this is the maximum number 39 // inserted, if the actual number is higher, it may trigger 40 // additional memory allocations. 41 // 42 // Currently a value of 1 is used as ingestion process ingests one 43 // invocation at a time. 44 VerdictsInsertedHint = 1 45 ) 46 47 type Buffer struct { 48 // Capacity of the hot buffer. If it is full, the content will be written 49 // into the cold buffer. 50 HotBufferCapacity int 51 HotBuffer History 52 // Capacity of the cold buffer. 53 ColdBufferCapacity int 54 ColdBuffer History 55 // IsColdBufferDirty will be set to 1 if the cold buffer is dirty. 56 // This means we need to write the cold buffer to Spanner. 57 IsColdBufferDirty bool 58 } 59 60 type History struct { 61 // Verdicts, sorted by commit position (oldest first), and 62 // then result time (oldest first). 63 Verdicts []PositionVerdict 64 } 65 66 type PositionVerdict struct { 67 // The commit position for the verdict. 68 CommitPosition int 69 // Denotes whether this verdict is a simple expected pass verdict or not. 70 // A simple expected pass verdict has only one test result, which is expected pass. 71 IsSimpleExpectedPass bool 72 // The partition time that this PositionVerdict was ingested. 73 // When stored, it is truncated to the nearest hour. 74 Hour time.Time 75 // The details of the verdict. 76 Details VerdictDetails 77 } 78 79 type VerdictDetails struct { 80 // Whether a verdict is exonerated or not. 81 IsExonerated bool 82 // Details of the runs in the verdict. 83 Runs []Run 84 } 85 86 type ResultCounts struct { 87 // Number of passed result. 88 PassCount int 89 // Number of failed result. 90 FailCount int 91 // Number of crashed result. 92 CrashCount int 93 // Number of aborted result. 94 AbortCount int 95 } 96 97 type Run struct { 98 // Counts for expected results. 99 Expected ResultCounts 100 // Counts for unexpected results. 101 Unexpected ResultCounts 102 // Whether this run is a duplicate run. 103 IsDuplicate bool 104 } 105 106 func (r ResultCounts) Count() int { 107 return r.PassCount + r.FailCount + r.CrashCount + r.AbortCount 108 } 109 110 type MergedInputBuffer struct { 111 inputBuffer *Buffer 112 // Buffer contains the merged verdicts. 113 Buffer History 114 } 115 116 // New allocates an empty input buffer with default capacity. 117 func New() *Buffer { 118 return NewWithCapacity(DefaultHotBufferCapacity, DefaultColdBufferCapacity) 119 } 120 121 // NewWithCapacity allocates an empty input buffer with the given capacity. 122 func NewWithCapacity(hotBufferCapacity, coldBufferCapacity int) *Buffer { 123 return &Buffer{ 124 HotBufferCapacity: hotBufferCapacity, 125 // HotBufferCapacity is a hard limit on the number of verdicts 126 // stored in Spanner, but only a soft limit during processing. 127 // After new verdicts are ingested, but before eviction is 128 // considered, the limit can be exceeded. 129 HotBuffer: History{Verdicts: make([]PositionVerdict, 0, hotBufferCapacity+VerdictsInsertedHint)}, 130 ColdBufferCapacity: coldBufferCapacity, 131 // ColdBufferCapacity is a hard limit on the number of verdicts 132 // stored in Spanner, but only a soft limit during processing. 133 // After new verdicts are ingested, but before eviction is 134 // considered, the limit can be exceeded (due to the new 135 // verdicts or due to compaction from hot buffer to cold buffer). 136 ColdBuffer: History{Verdicts: make([]PositionVerdict, 0, coldBufferCapacity+hotBufferCapacity+VerdictsInsertedHint)}, 137 } 138 } 139 140 // Copy makes a deep copy of the input buffer. 141 func (ib *Buffer) Copy() *Buffer { 142 return &Buffer{ 143 HotBufferCapacity: ib.HotBufferCapacity, 144 HotBuffer: ib.HotBuffer.Copy(), 145 ColdBufferCapacity: ib.ColdBufferCapacity, 146 ColdBuffer: ib.ColdBuffer.Copy(), 147 IsColdBufferDirty: ib.IsColdBufferDirty, 148 } 149 } 150 151 // Copy makes a deep copy of the History. 152 func (h History) Copy() History { 153 // Make a deep copy of verdicts. 154 verdictsCopy := make([]PositionVerdict, len(h.Verdicts), cap(h.Verdicts)) 155 copy(verdictsCopy, h.Verdicts) 156 157 // Including the nested runs slice. 158 for i, v := range verdictsCopy { 159 if v.Details.Runs != nil { 160 runsCopy := make([]Run, len(v.Details.Runs)) 161 copy(runsCopy, v.Details.Runs) 162 verdictsCopy[i].Details.Runs = runsCopy 163 } 164 } 165 166 return History{Verdicts: verdictsCopy} 167 } 168 169 // EvictBefore removes all verdicts prior (but not including) the given index. 170 // 171 // This will modify the verdicts buffer in-place, existing subslices 172 // should be treated as invalid following this operation. 173 func (h *History) EvictBefore(index int) { 174 // Instead of the obvious: 175 // h.Verdicts = h.Verdicts[index:] 176 // We shuffle all items forward by index so that we retain 177 // the same underlying Verdicts buffer, with the same capacity. 178 179 // Shuffle all items forward by 'index'. 180 for i := index; i < len(h.Verdicts); i++ { 181 h.Verdicts[i-index] = h.Verdicts[i] 182 } 183 h.Verdicts = h.Verdicts[:len(h.Verdicts)-index] 184 } 185 186 // Clear resets the input buffer to an empty state, similar to 187 // its state after New(). 188 func (ib *Buffer) Clear() { 189 if cap(ib.HotBuffer.Verdicts) < ib.HotBufferCapacity+VerdictsInsertedHint { 190 // Indicates a logic error if someone discarded part of the 191 // originally allocated buffer. 192 panic("buffer capacity unexpectedly modified") 193 } 194 if cap(ib.ColdBuffer.Verdicts) < ib.ColdBufferCapacity+ib.HotBufferCapacity+VerdictsInsertedHint { 195 // Indicates a logic error if someone discarded part of the 196 // originally allocated buffer. 197 panic("buffer capacity unexpectedly modified") 198 } 199 ib.HotBuffer.Verdicts = ib.HotBuffer.Verdicts[:0] 200 ib.ColdBuffer.Verdicts = ib.ColdBuffer.Verdicts[:0] 201 ib.IsColdBufferDirty = false 202 } 203 204 // InsertVerdict inserts a new verdict into the input buffer. 205 // It will first try to insert in the hot buffer, and if the hot buffer is full 206 // as the result of the insert, then a compaction will occur. 207 // If a compaction occurs, the IsColdBufferDirty flag will be set to true, 208 // implying that the cold buffer content needs to be written to Spanner. 209 func (ib *Buffer) InsertVerdict(v PositionVerdict) { 210 // Find the position to insert the verdict. 211 // As the new verdict is likely to have the latest commit position, we 212 // will iterate backwards from the end of the slice. 213 verdicts := ib.HotBuffer.Verdicts 214 pos := len(verdicts) 215 for ; pos > 0; pos-- { 216 if compareVerdict(v, verdicts[pos-1]) == 1 { 217 // verdict is after the verdict at position-1, 218 // so insert at position. 219 break 220 } 221 } 222 223 // Shuffle all verdicts in verdicts[pos:] forwards 224 // to create a spot at the insertion position. 225 // (We want to avoid allocating a new slice.) 226 verdicts = append(verdicts, PositionVerdict{}) 227 for i := len(verdicts) - 1; i > pos; i-- { 228 verdicts[i] = verdicts[i-1] 229 } 230 verdicts[pos] = v 231 ib.HotBuffer.Verdicts = verdicts 232 233 if len(verdicts) == ib.HotBufferCapacity { 234 ib.IsColdBufferDirty = true 235 ib.Compact() 236 } 237 } 238 239 // Compact moves the content from the hot buffer to the cold buffer. 240 // Note: It is possible that the cold buffer overflows after the compaction, 241 // i.e., len(ColdBuffer.Verdicts) > ColdBufferCapacity. 242 // This needs to be handled separately. 243 func (ib *Buffer) Compact() { 244 var merged []PositionVerdict 245 ib.MergeBuffer(&merged) 246 ib.HotBuffer.Verdicts = ib.HotBuffer.Verdicts[:0] 247 248 // Copy the merged verdicts to the ColdBuffer instead of assigning 249 // the merged buffer, so that we keep the same pre-allocated buffer. 250 ib.ColdBuffer.Verdicts = ib.ColdBuffer.Verdicts[:0] 251 for _, v := range merged { 252 ib.ColdBuffer.Verdicts = append(ib.ColdBuffer.Verdicts, v) 253 } 254 } 255 256 // MergeBuffer merges the verdicts of the hot buffer and the cold buffer 257 // into the provided slice, resizing it if necessary. 258 // The returned slice will be sorted by commit position (oldest first), and 259 // then by result time (oldest first). 260 func (ib *Buffer) MergeBuffer(destination *[]PositionVerdict) { 261 // Because the hot buffer and cold buffer are both sorted, we can simply use 262 // a single merge to merge the 2 buffers. 263 hVerdicts := ib.HotBuffer.Verdicts 264 cVerdicts := ib.ColdBuffer.Verdicts 265 266 if *destination == nil { 267 *destination = make([]PositionVerdict, 0, ib.ColdBufferCapacity+ib.HotBufferCapacity+VerdictsInsertedHint) 268 } 269 270 // Reset destination slice to zero length. 271 merged := (*destination)[:0] 272 273 hPos := 0 274 cPos := 0 275 for hPos < len(hVerdicts) && cPos < len(cVerdicts) { 276 cmp := compareVerdict(hVerdicts[hPos], cVerdicts[cPos]) 277 // Item in hot buffer is strictly older. 278 if cmp == -1 { 279 merged = append(merged, hVerdicts[hPos]) 280 hPos++ 281 } else { 282 merged = append(merged, cVerdicts[cPos]) 283 cPos++ 284 } 285 } 286 287 // Add the remaining items. 288 for ; hPos < len(hVerdicts); hPos++ { 289 merged = append(merged, hVerdicts[hPos]) 290 } 291 for ; cPos < len(cVerdicts); cPos++ { 292 merged = append(merged, cVerdicts[cPos]) 293 } 294 295 *destination = merged 296 } 297 298 // EvictionRange returns the part that should be evicted from cold buffer, due 299 // to overflow. 300 // Note: we never evict from the hot buffer due to overflow. Overflow from the 301 // hot buffer should cause compaction to the cold buffer instead. 302 // Returns: 303 // - a boolean (shouldEvict) to indicated if an eviction should occur. 304 // - a number (endIndex) for the eviction. The eviction will occur for range 305 // [0, endIndex (inclusively)]. 306 // Note that eviction can only occur after a compaction from hot buffer to cold 307 // buffer. It means the hot buffer is empty, and the cold buffer overflows. 308 func (ib *Buffer) EvictionRange() (shouldEvict bool, endIndex int) { 309 if len(ib.ColdBuffer.Verdicts) <= ib.ColdBufferCapacity { 310 return false, 0 311 } 312 if len(ib.HotBuffer.Verdicts) > 0 { 313 panic("hot buffer is not empty during eviction") 314 } 315 return true, len(ib.ColdBuffer.Verdicts) - ib.ColdBufferCapacity - 1 316 } 317 318 func (ib *Buffer) Size() int { 319 return len(ib.ColdBuffer.Verdicts) + len(ib.HotBuffer.Verdicts) 320 } 321 322 // HistorySerializer provides methods to decode and encode History objects. 323 // Methods on a given instance are only safe to call on one goroutine at 324 // a time. 325 type HistorySerializer struct { 326 // A preallocated buffer to store encoded, uncompressed verdicts. 327 // Avoids needing to allocate a new buffer for every decode/encode 328 // operation, with consequent heap requirements and GC churn. 329 tempBuf []byte 330 } 331 332 // ensureAndClearBuf returns a temporary buffer with suitable capacity 333 // and zero length. 334 func (hs *HistorySerializer) ensureAndClearBuf() { 335 if hs.tempBuf == nil { 336 // At most the history will have 2000 verdicts (cold buffer). 337 // Most verdicts will be simple expected verdict, so 30,000 is probably fine 338 // for most cases. 339 // In case 30,000 bytes is not enough, Encode() or Decode() 340 // will resize to an appropriate size. 341 hs.tempBuf = make([]byte, 0, 30000) 342 } else { 343 hs.tempBuf = hs.tempBuf[:0] 344 } 345 } 346 347 // Encode uses varint encoding to encode history into a byte array. 348 // See go/luci-test-variant-analysis-design for details. 349 func (hs *HistorySerializer) Encode(history History) []byte { 350 hs.ensureAndClearBuf() 351 buf := hs.tempBuf 352 buf = binary.AppendUvarint(buf, uint64(EncodingVersion)) 353 buf = binary.AppendUvarint(buf, uint64(len(history.Verdicts))) 354 355 var lastPosition uint64 356 var lastHourNumber int64 357 for _, verdict := range history.Verdicts { 358 // We encode the relative deltaPosition between the current verdict and the 359 // previous verdicts. 360 deltaPosition := uint64(verdict.CommitPosition) - lastPosition 361 deltaPosition = deltaPosition << 1 362 if !verdict.IsSimpleExpectedPass { 363 // Set the last bit to 1 if it is not a simple verdict. 364 deltaPosition |= 1 365 } 366 buf = binary.AppendUvarint(buf, deltaPosition) 367 lastPosition = uint64(verdict.CommitPosition) 368 369 // Encode the "relative" hour. 370 // Note that the relative hour may be positive or negative. So we are encoding 371 // it as varint. 372 hourNumber := verdict.Hour.Unix() / 3600 373 deltaHour := hourNumber - lastHourNumber 374 buf = binary.AppendVarint(buf, deltaHour) 375 lastHourNumber = hourNumber 376 377 // Encode the verdict details, only if not simple verdict. 378 if !verdict.IsSimpleExpectedPass { 379 buf = appendVerdictDetails(buf, verdict.Details) 380 } 381 } 382 // It is possible the size of buf was increased in this method. 383 // If so, keep that larger buf for future encodings. 384 hs.tempBuf = buf 385 386 // Use zstd to compress the result. Note that the buffer returned 387 // by Compress is always different to hs.tempBuf, so tempBuf does 388 // not escape. 389 return span.Compress(buf) 390 } 391 392 // DecodeInto decodes the verdicts in buf, populating the history object. 393 func (hs *HistorySerializer) DecodeInto(history *History, buf []byte) error { 394 // Clear existing verdicts to avoid state from a previous 395 // decoding leaking. 396 verdicts := history.Verdicts[:0] 397 398 var err error 399 hs.ensureAndClearBuf() 400 // If it is possible hs.tempBuf was resized to be able to accept 401 // all the decompressed content. If so, keep it, so we can use 402 // the larger buf for future decodings. 403 hs.tempBuf, err = span.Decompress(buf, hs.tempBuf) 404 if err != nil { 405 return errors.Annotate(err, "decompress error").Err() 406 } 407 reader := bytes.NewReader(hs.tempBuf) 408 409 // Read version. 410 version, err := binary.ReadUvarint(reader) 411 if err != nil { 412 return errors.Annotate(err, "read version").Err() 413 } 414 if version != EncodingVersion { 415 return fmt.Errorf("version mismatched: got version %d, want %d", version, EncodingVersion) 416 } 417 418 // Read verdicts. 419 nVerdicts, err := binary.ReadUvarint(reader) 420 if err != nil { 421 return errors.Annotate(err, "read number of verdicts").Err() 422 } 423 if nVerdicts > uint64(cap(verdicts)) { 424 // The caller has allocated an inappropriately sized buffer. 425 return errors.Reason("found %v verdicts to decode, but capacity is only %v", nVerdicts, cap(verdicts)).Err() 426 } 427 428 for i := 0; i < int(nVerdicts); i++ { 429 // Get the commit position for the verdicts, and if the verdict is simple 430 // expected. 431 verdict := PositionVerdict{} 432 posSim, err := binary.ReadUvarint(reader) 433 if err != nil { 434 return errors.Annotate(err, "read position simple verdict").Err() 435 } 436 deltaPos, isSimple := decodePositionSimpleVerdict(posSim) 437 438 verdict.IsSimpleExpectedPass = isSimple 439 // First verdict, deltaPos should be the absolute commit position. 440 if i == 0 { 441 verdict.CommitPosition = deltaPos 442 } else { 443 // deltaPos records the relative difference. 444 verdict.CommitPosition = verdicts[i-1].CommitPosition + deltaPos 445 } 446 447 // Get the hour. 448 deltaHour, err := binary.ReadVarint(reader) 449 if err != nil { 450 return errors.Annotate(err, "read delta hour").Err() 451 } 452 if i == 0 { 453 verdict.Hour = time.Unix(deltaHour*3600, 0) 454 } else { 455 secs := verdicts[i-1].Hour.Unix() 456 verdict.Hour = time.Unix(secs+deltaHour*3600, 0) 457 } 458 459 // Read the verdict details. 460 if !isSimple { 461 vd, err := readVerdictDetails(reader) 462 if err != nil { 463 return errors.Annotate(err, "read verdict details").Err() 464 } 465 verdict.Details = vd 466 } 467 verdicts = append(verdicts, verdict) 468 } 469 history.Verdicts = verdicts 470 471 return err 472 } 473 474 func readVerdictDetails(reader *bytes.Reader) (VerdictDetails, error) { 475 vd := VerdictDetails{} 476 // Get IsExonerated. 477 exoInt, err := binary.ReadUvarint(reader) 478 if err != nil { 479 return vd, errors.Annotate(err, "read exoneration status").Err() 480 } 481 vd.IsExonerated = uInt64ToBool(exoInt) 482 483 // Get runs. 484 runCount, err := binary.ReadUvarint(reader) 485 if err != nil { 486 return vd, errors.Annotate(err, "read run count").Err() 487 } 488 vd.Runs = make([]Run, runCount) 489 for i := 0; i < int(runCount); i++ { 490 run, err := readRun(reader) 491 if err != nil { 492 return vd, errors.Annotate(err, "read run").Err() 493 } 494 vd.Runs[i] = run 495 } 496 return vd, nil 497 } 498 499 func readRun(reader *bytes.Reader) (Run, error) { 500 r := Run{} 501 // Read expected passed count. 502 expectedPassedCount, err := binary.ReadUvarint(reader) 503 if err != nil { 504 return r, errors.Annotate(err, "read expected passed count").Err() 505 } 506 r.Expected.PassCount = int(expectedPassedCount) 507 508 // Read expected failed count. 509 expectedFailedCount, err := binary.ReadUvarint(reader) 510 if err != nil { 511 return r, errors.Annotate(err, "read expected failed count").Err() 512 } 513 r.Expected.FailCount = int(expectedFailedCount) 514 515 // Read expected crashed count. 516 expectedCrashedCount, err := binary.ReadUvarint(reader) 517 if err != nil { 518 return r, errors.Annotate(err, "read expected crashed count").Err() 519 } 520 r.Expected.CrashCount = int(expectedCrashedCount) 521 522 // Read expected aborted count. 523 expectedAbortedCount, err := binary.ReadUvarint(reader) 524 if err != nil { 525 return r, errors.Annotate(err, "read expected aborted count").Err() 526 } 527 r.Expected.AbortCount = int(expectedAbortedCount) 528 529 // Read unexpected passed count. 530 unexpectedPassedCount, err := binary.ReadUvarint(reader) 531 if err != nil { 532 return r, errors.Annotate(err, "read unexpected passed count").Err() 533 } 534 r.Unexpected.PassCount = int(unexpectedPassedCount) 535 536 // Read unexpected failed count. 537 unexpectedFailedCount, err := binary.ReadUvarint(reader) 538 if err != nil { 539 return r, errors.Annotate(err, "read unexpected failed count").Err() 540 } 541 r.Unexpected.FailCount = int(unexpectedFailedCount) 542 543 // Read unexpected crashed count. 544 unexpectedCrashedCount, err := binary.ReadUvarint(reader) 545 if err != nil { 546 return r, errors.Annotate(err, "read unexpected crashed count").Err() 547 } 548 r.Unexpected.CrashCount = int(unexpectedCrashedCount) 549 550 // Read unexpected aborted count. 551 unexpectedAbortedCount, err := binary.ReadUvarint(reader) 552 if err != nil { 553 return r, errors.Annotate(err, "read unexpected aborted count").Err() 554 } 555 r.Unexpected.AbortCount = int(unexpectedAbortedCount) 556 557 // Read isDuplicate 558 isDuplicate, err := binary.ReadUvarint(reader) 559 if err != nil { 560 return r, errors.Annotate(err, "read is duplicate").Err() 561 } 562 r.IsDuplicate = uInt64ToBool(isDuplicate) 563 564 return r, nil 565 } 566 567 func appendVerdictDetails(result []byte, vd VerdictDetails) []byte { 568 // Encode IsExonerated. 569 result = binary.AppendUvarint(result, boolToUInt64(vd.IsExonerated)) 570 571 // Encode runs. 572 result = binary.AppendUvarint(result, uint64(len(vd.Runs))) 573 for _, r := range vd.Runs { 574 result = appendRun(result, r) 575 } 576 return result 577 } 578 579 func appendRun(result []byte, run Run) []byte { 580 result = binary.AppendUvarint(result, uint64(run.Expected.PassCount)) 581 result = binary.AppendUvarint(result, uint64(run.Expected.FailCount)) 582 result = binary.AppendUvarint(result, uint64(run.Expected.CrashCount)) 583 result = binary.AppendUvarint(result, uint64(run.Expected.AbortCount)) 584 result = binary.AppendUvarint(result, uint64(run.Unexpected.PassCount)) 585 result = binary.AppendUvarint(result, uint64(run.Unexpected.FailCount)) 586 result = binary.AppendUvarint(result, uint64(run.Unexpected.CrashCount)) 587 result = binary.AppendUvarint(result, uint64(run.Unexpected.AbortCount)) 588 result = binary.AppendUvarint(result, boolToUInt64(run.IsDuplicate)) 589 return result 590 } 591 592 // decodePositionSimpleVerdict decodes the value posSim and returns 2 values. 593 // 1. The (delta) commit position. 594 // 2. Whether the verdict is a simple expected passed verdict. 595 // The last bit of posSim is set to 1 if the verdict is NOT a simple expected pass. 596 func decodePositionSimpleVerdict(posSim uint64) (int, bool) { 597 isSimple := false 598 lastBit := posSim & 1 599 if lastBit == 0 { 600 isSimple = true 601 } 602 deltaPos := posSim >> 1 603 return int(deltaPos), isSimple 604 } 605 606 func boolToUInt64(b bool) uint64 { 607 result := 0 608 if b { 609 result = 1 610 } 611 return uint64(result) 612 } 613 614 func uInt64ToBool(u uint64) bool { 615 return u == 1 616 } 617 618 // compareVerdict returns 1 if v1 is later than v2, -1 if v1 is earlier than 619 // v2, and 0 if they are at the same time. 620 // The comparision is done on commit position, then on hour. 621 func compareVerdict(v1 PositionVerdict, v2 PositionVerdict) int { 622 if v1.CommitPosition > v2.CommitPosition { 623 return 1 624 } 625 if v1.CommitPosition < v2.CommitPosition { 626 return -1 627 } 628 if v1.Hour.Unix() > v2.Hour.Unix() { 629 return 1 630 } 631 if v1.Hour.Unix() < v2.Hour.Unix() { 632 return -1 633 } 634 return 0 635 }