github.com/decred/politeia@v1.4.0/politeiad/backendv2/tstorebe/plugins/ticketvote/inv.go (about) 1 // Copyright (c) 2022 The Decred developers 2 // Use of this source code is governed by an ISC 3 // license that can be found in the LICENSE file. 4 5 package ticketvote 6 7 import ( 8 "encoding/json" 9 "fmt" 10 "sort" 11 "sync" 12 13 backend "github.com/decred/politeia/politeiad/backendv2" 14 "github.com/decred/politeia/politeiad/backendv2/tstorebe/plugins" 15 "github.com/decred/politeia/politeiad/plugins/ticketvote" 16 "github.com/pkg/errors" 17 ) 18 19 // inv represents the ticketvote inventory. 20 // 21 // The unauthorized, authorized, and started lists are updated in real-time 22 // since ticketvote plugin commands and hooks initiate those actions. 23 // 24 // The finished, approved, and rejected statuses are lazy loaded since those 25 // lists depend on external state (the DCR block height). 26 // 27 // The invClient structure provides an API for interacting with the ticketvote 28 // inventory. 29 type inv struct { 30 // Entries contains the inventory entries categorized by vote 31 // status and sorted from oldest to newest. 32 // 33 // Entries that are pre vote are sorted by the timestamp of the 34 // vote status change. Entries that have begun voting or are post 35 // vote are sorted by the vote's end block height. 36 Entries map[ticketvote.VoteStatusT][]invEntry `json:"entries"` 37 38 // BlockHeight is the block height that the inventory has been 39 // updated with. 40 BlockHeight uint32 `json:"block_height"` 41 } 42 43 // newInv returns a new inv. 44 func newInv() *inv { 45 return &inv{ 46 Entries: make(map[ticketvote.VoteStatusT][]invEntry), 47 BlockHeight: 0, 48 } 49 } 50 51 // Add adds an entry to the inventory. The entry is prepended onto the list 52 // that contains the other entries with the same vote status. 53 func (i *inv) Add(e invEntry) { 54 entries, ok := i.Entries[e.Status] 55 if !ok { 56 entries = make([]invEntry, 0, 64) 57 } 58 i.Entries[e.Status] = append([]invEntry{e}, entries...) 59 } 60 61 // Del deletes an entry from the inventory. 62 // 63 // Status is the current status of the inventory entry. 64 func (i *inv) Del(token string, status ticketvote.VoteStatusT) error { 65 // Find the existing entry 66 entries := i.Entries[status] 67 var ( 68 idx int // Index of target entry 69 found bool 70 ) 71 for k, v := range entries { 72 if v.Token == token { 73 idx = k 74 found = true 75 break 76 } 77 } 78 if !found { 79 return fmt.Errorf("entry not found %v %v", token, status) 80 } 81 82 // Delete the entry from the list (linear time) 83 copy(entries[idx:], entries[idx+1:]) // Shift entries[i+1:] left one index 84 entries[len(entries)-1] = invEntry{} // Del last element (write zero value) 85 entries = entries[:len(entries)-1] // Truncate slice 86 87 // Save the updated list 88 i.Entries[status] = entries 89 90 return nil 91 } 92 93 // Sort sorts the inventory entries. 94 // 95 // The inventory entries are categorized by vote status and sorted from newest 96 // to oldest. The vote statuses that occur prior to the start of the voting 97 // period are sorted by the timestamp of the vote status change. The vote 98 // statuses that occur after a vote has been started or has finished are sorted 99 // by the vote's end block height. 100 func (i *inv) Sort() { 101 for status, entries := range i.Entries { 102 switch status { 103 case ticketvote.VoteStatusUnauthorized, 104 ticketvote.VoteStatusAuthorized, 105 ticketvote.VoteStatusIneligible: 106 107 // Sort by the timestamps from newest to oldest 108 sort.SliceStable(entries, func(i, j int) bool { 109 return entries[i].Timestamp > entries[j].Timestamp 110 }) 111 112 case ticketvote.VoteStatusStarted, 113 ticketvote.VoteStatusFinished, 114 ticketvote.VoteStatusApproved, 115 ticketvote.VoteStatusRejected: 116 117 // Sort by the end block heights from newest to oldest 118 sort.SliceStable(entries, func(i, j int) bool { 119 return entries[i].EndBlockHeight > entries[j].EndBlockHeight 120 }) 121 122 default: 123 // Should not happen 124 e := fmt.Sprintf("unknown vote status %v", status) 125 panic(e) 126 } 127 128 i.Entries[status] = entries 129 } 130 } 131 132 // GetPage returns a page of inventory entries. 133 func (i *inv) GetPage(status ticketvote.VoteStatusT, pageNumber, pageSize uint32) []invEntry { 134 entries, ok := i.Entries[status] 135 if !ok { 136 return []invEntry{} 137 } 138 if pageSize == 0 || pageNumber == 0 { 139 return []invEntry{} 140 } 141 var ( 142 startIdx = int((pageNumber - 1) * pageSize) // Inclusive 143 endIdx = startIdx + int(pageSize) // Exclusive 144 ) 145 if startIdx >= len(entries) { 146 return []invEntry{} 147 } 148 if endIdx >= len(entries) { 149 // The inventory does not contain a full 150 // page of entries at the requested page 151 // number. Return a partial page. 152 return entries[startIdx:] 153 } 154 // Return a full page of entries 155 return entries[startIdx:endIdx] 156 } 157 158 // invEntry is an entry in the ticketvote inventory. 159 type invEntry struct { 160 Token string `json:"token"` 161 Status ticketvote.VoteStatusT `json:"status"` 162 163 // Timestamp is the timestamp of the last vote status change. This 164 // is used to order the inventory entries for records that have not 165 // yet started voting. Once the vote has begun for a record, this 166 // field will be set to 0 and the EndHeight field will be used for 167 // ordering. 168 Timestamp int64 `json:"timestamp,omitempty"` 169 170 // EndBlockHeight is the end block height of the vote. This is used 171 // to order the inventory entries of records that are being voted 172 // on or have already been voted on. This field will be set to 0 if 173 // the vote has not begun yet. 174 EndBlockHeight uint32 `json:"endblockheight,omitempty"` 175 } 176 177 // newInvEntry returns a new invEntry. 178 func newInvEntry(token string, status ticketvote.VoteStatusT, timestamp int64, endBlockHeight uint32) *invEntry { 179 return &invEntry{ 180 Token: token, 181 Status: status, 182 Timestamp: timestamp, 183 EndBlockHeight: endBlockHeight, 184 } 185 } 186 187 // invClient provides an API for interacting with the cached ticketvote 188 // inventory. The inventory is saved to the TstoreClient provided plugin 189 // cache. 190 // 191 // A mutex is required because tstore does not provide plugins with a sql 192 // transaction that can be used to execute multiple database requests 193 // atomically. Concurrent access to the inventory cache during updates must 194 // be control locally using a mutex for now. 195 // 196 // This implementation will have performance limitations once the inventory 197 // gets large enough. Probably once the number of records gets into the 198 // thousands. This will not be an issue for Decred for quite a while and by the 199 // time it does become an issue, the plugins should have much more 200 // sophisticated caching API available to them, such as the ability to create 201 // their own db tables that they can run sql queries against. 202 type invClient struct { 203 sync.Mutex 204 tstore plugins.TstoreClient 205 backend backend.Backend 206 pageSize uint32 207 } 208 209 // newInvClient returns a new invClient. 210 func newInvClient(tstore plugins.TstoreClient, backend backend.Backend, pageSize uint32) *invClient { 211 return &invClient{ 212 tstore: tstore, 213 backend: backend, 214 pageSize: pageSize, 215 } 216 } 217 218 // AddEntry adds a new entry to the inventory. 219 // 220 // New entries will always correspond to a vote status that has not been voted 221 // on yet. This is why a timestamp is required and not the end height. The 222 // timestamp of the timestamp of the vote status change. 223 // 224 // Plugin writes are not currently executed using a sql transaction, which 225 // means that there is no way to unwind previous writes if this cache update 226 // fails. For this reason, we panic instead of returning an error so that the 227 // sysadmin is alerted that the cache is incoherent and needs to be rebuilt. 228 // 229 // This function is concurrency safe. 230 func (c *invClient) AddEntry(token string, status ticketvote.VoteStatusT, timestamp int64) { 231 c.Lock() 232 defer c.Unlock() 233 234 err := c.addEntry(token, status, timestamp) 235 if err != nil { 236 e := fmt.Sprintf("%v %v %v: %v", token, status, timestamp, err) 237 panic(e) 238 } 239 } 240 241 // UpdateEntryPreVote updates an entry in the inventory whose voting period has 242 // not yet begun. The timestamp is the timestamp of the vote status change. 243 // The inventory entries whose voting period has not yet begun are ordered 244 // using this timestamp. 245 // 246 // Plugin writes are not currently executed using a sql transaction, which 247 // means that there is no way to unwind previous writes if this cache update 248 // fails. For this reason, we panic instead of returning an error so that the 249 // sysadmin is alerted that the cache is incoherent and needs to be rebuilt. 250 // 251 // This function is concurrency safe. 252 func (c *invClient) UpdateEntryPreVote(token string, status ticketvote.VoteStatusT, timestamp int64) { 253 c.Lock() 254 defer c.Unlock() 255 256 err := c.updateEntry(token, status, timestamp, 0) 257 if err != nil { 258 e := fmt.Sprintf("%v %v %v: %v", token, status, timestamp, err) 259 panic(e) 260 } 261 } 262 263 // UpdateEntryPostVote updates an entry in the inventory whose voting period 264 // has been started or has already finished. The inventory entries that fall 265 // into this category are ordered by the endBlockHeight of the voting period. 266 // 267 // Plugin writes are not currently executed using a sql transaction, which 268 // means that there is no way to unwind previous writes if this cache update 269 // fails. For this reason, we panic instead of returning an error so that the 270 // sysadmin is alerted that the cache is incoherent and needs to be rebuilt. 271 // 272 // This function is concurrency safe. 273 func (c *invClient) UpdateEntryPostVote(token string, status ticketvote.VoteStatusT, endBlockHeight uint32) { 274 c.Lock() 275 defer c.Unlock() 276 277 err := c.updateEntry(token, status, 0, endBlockHeight) 278 if err != nil { 279 e := fmt.Sprintf("%v %v %v: %v", token, status, endBlockHeight, err) 280 panic(e) 281 } 282 } 283 284 // Page returns a page of inventory results for all vote statuses. 285 // 286 // The best block is required to ensure that the returned results are 287 // up-to-date. Certain inventory statuses, such as VoteStatusFinished, are 288 // updated based on the vote's ending block height and the best block. 289 // 290 // This function is concurrency safe. 291 func (c *invClient) GetPage(bestBlock uint32) (*inv, error) { 292 c.Lock() 293 defer c.Unlock() 294 295 fullInv, err := c.updateBlockHeight(bestBlock) 296 if err != nil { 297 return nil, err 298 } 299 invPage := newInv() 300 for status := range fullInv.Entries { 301 invPage.Entries[status] = fullInv.GetPage(status, 1, c.pageSize) 302 } 303 304 return invPage, nil 305 } 306 307 // PageForStatus returns a page of inventory results for the provided vote 308 // status. 309 // 310 // Page 1 corresponds to the most recent page of inventory entries. 311 // 312 // This function is concurrency safe. 313 func (c *invClient) GetPageForStatus(bestBlock uint32, status ticketvote.VoteStatusT, pageNumber uint32) ([]invEntry, error) { 314 c.Lock() 315 defer c.Unlock() 316 317 fullInv, err := c.updateBlockHeight(bestBlock) 318 if err != nil { 319 return nil, err 320 } 321 322 return fullInv.GetPage(status, pageNumber, c.pageSize), nil 323 } 324 325 // Rebuild rebuilds the inventory using the provided inventory entries and 326 // saves it to the tstore plugin cache. 327 // 328 // This function is concurrency safe. 329 func (c *invClient) Rebuild(entries []invEntry) error { 330 c.Lock() 331 defer c.Unlock() 332 333 inv := newInv() 334 for _, v := range entries { 335 inv.Add(v) 336 } 337 inv.Sort() 338 339 return c.saveInv(*inv) 340 } 341 342 // addEntry adds a new entry to the inventory. 343 // 344 // New entries will always correspond to a vote status that has not been voted 345 // on yet. This is why a timestamp is required and not the end height. The 346 // timestamp of the timestamp of the vote status change. 347 // 348 // This function is not concurrency safe. It must be called with the mutex 349 // locked. 350 func (c *invClient) addEntry(token string, status ticketvote.VoteStatusT, timestamp int64) error { 351 inv, err := c.getInv() 352 if err != nil { 353 return err 354 } 355 356 e := newInvEntry(token, status, timestamp, 0) 357 inv.Add(*e) 358 359 err = c.saveInv(*inv) 360 if err != nil { 361 return err 362 } 363 364 s := ticketvote.VoteStatuses[status] 365 log.Debugf("Vote inv entry added %v %v", token, s) 366 367 return nil 368 } 369 370 // updateEntry updates an existing inventory entry. The existing entry is 371 // deleted from the inventory and a new entry is added using the provided 372 // arguments. The updated inventory is saved to the tstore plugin cache. 373 // 374 // This function is not concurrency safe. It must be called with the mutex 375 // locked. 376 func (c *invClient) updateEntry(token string, status ticketvote.VoteStatusT, timestamp int64, endBlockHeight uint32) error { 377 // Get the existing inventory 378 inv, err := c.getInv() 379 if err != nil { 380 return err 381 } 382 383 // We must first delete the existing entry from the inventory 384 // before we can add the updated entry. To do this, we need 385 // to know the vote status of the existing entry. We ascertain 386 // this info using the vote status of the updated entry. For 387 // example, an entry that is being updated to the status of 388 // VoteStatusStarted must currently exist in the inventory 389 // under the status of VoteStatusAuthorized. 390 var ( 391 // statusesToScan is populated with the vote statuses that 392 // will be scanned in order to find the existing entry. 393 statusesToScan []ticketvote.VoteStatusT 394 395 // prevStatus is the status of the record's existing 396 // inventory entry. We need to know this in order to 397 // delete the existing entry. 398 prevStatus ticketvote.VoteStatusT 399 ) 400 switch status { 401 case ticketvote.VoteStatusUnauthorized: 402 statusesToScan = []ticketvote.VoteStatusT{ 403 ticketvote.VoteStatusAuthorized, 404 } 405 406 case ticketvote.VoteStatusAuthorized: 407 statusesToScan = []ticketvote.VoteStatusT{ 408 ticketvote.VoteStatusUnauthorized, 409 } 410 411 case ticketvote.VoteStatusStarted: 412 statusesToScan = []ticketvote.VoteStatusT{ 413 ticketvote.VoteStatusAuthorized, 414 } 415 416 case ticketvote.VoteStatusFinished, 417 ticketvote.VoteStatusApproved, 418 ticketvote.VoteStatusRejected: 419 statusesToScan = []ticketvote.VoteStatusT{ 420 ticketvote.VoteStatusStarted, 421 } 422 423 case ticketvote.VoteStatusIneligible: 424 statusesToScan = []ticketvote.VoteStatusT{ 425 ticketvote.VoteStatusAuthorized, 426 ticketvote.VoteStatusUnauthorized, 427 } 428 429 default: 430 // This should not happen. If this path is getting hit then 431 // there is likely a bug somewhere. Log an error instead of 432 // returning one so that the caller does not panic. Search 433 // the full inventory. An error will be returned below if 434 // the token is not found in the inventory. 435 log.Errorf("Update vote inv entry unknown status %v %v", token, status) 436 for s, entries := range inv.Entries { 437 if entriesIncludeToken(entries, token) { 438 prevStatus = s 439 break 440 } 441 } 442 } 443 444 // Find the existing inventory entry for the record 445 for _, s := range statusesToScan { 446 entries, ok := inv.Entries[s] 447 if !ok { 448 continue 449 } 450 if entriesIncludeToken(entries, token) { 451 prevStatus = s 452 break 453 } 454 } 455 456 // Delete the existing entry then add the updated entry to 457 // the inventory. 458 err = inv.Del(token, prevStatus) 459 if err != nil { 460 return err 461 } 462 e := newInvEntry(token, status, timestamp, endBlockHeight) 463 inv.Add(*e) 464 465 // Save the updated inventory 466 err = c.saveInv(*inv) 467 if err != nil { 468 return err 469 } 470 471 var ( 472 prevStatusStr = ticketvote.VoteStatuses[prevStatus] 473 statusStr = ticketvote.VoteStatuses[status] 474 ) 475 log.Debugf("Vote inv update %v from %v to %v", 476 token, prevStatusStr, statusStr) 477 478 return nil 479 } 480 481 // updateBlockHeight updates the inventory with a new block height. Any votes 482 // that have ended based on the new block height are updated in the inventory 483 // based on the vote's outcome (passed, failed, etc). 484 // 485 // This function is not concurrency safe. It must be called with the mutex 486 // locked. 487 func (c *invClient) updateBlockHeight(blockHeight uint32) (*inv, error) { 488 inv, err := c.getInv() 489 if err != nil { 490 return nil, err 491 } 492 if inv.BlockHeight == blockHeight { 493 // Inventory is up-to-date 494 return inv, nil 495 } 496 497 // Compile the votes that have ended since the previous 498 // update. 499 ended := make([]invEntry, 0, 256) 500 started := inv.Entries[ticketvote.VoteStatusStarted] 501 for _, v := range started { 502 if voteHasEnded(blockHeight, v.EndBlockHeight) { 503 ended = append(ended, v) 504 } 505 } 506 507 // Sort by end height from oldest to newest so that 508 // they're added to the inventory in the correct order. 509 // They are prepended onto the inventory list so we 510 // want the newest to be added last. 511 sort.SliceStable(ended, func(i, j int) bool { 512 return ended[i].EndBlockHeight < ended[j].EndBlockHeight 513 }) 514 515 // Update the inventory entries whose vote has ended. 516 // We need to get the vote summary for each entry to 517 // determine if the vote passed or failed. 518 for _, v := range ended { 519 s, err := c.summary(v.Token) 520 if err != nil { 521 return nil, err 522 } 523 switch s.Status { 524 case ticketvote.VoteStatusFinished, 525 ticketvote.VoteStatusApproved, 526 ticketvote.VoteStatusRejected: 527 // These statuses are expected. Update the entry in 528 // the inventory. 529 err = inv.Del(v.Token, ticketvote.VoteStatusStarted) 530 if err != nil { 531 return nil, err 532 } 533 e := newInvEntry(v.Token, s.Status, 0, s.EndBlockHeight) 534 inv.Add(*e) 535 536 default: 537 // Something went wrong 538 return nil, errors.Errorf("unexpected vote status %v %v", 539 v.Token, s.Status) 540 } 541 } 542 543 // Update the inventory block height 544 inv.BlockHeight = blockHeight 545 546 // Save the updated inventory 547 err = c.saveInv(*inv) 548 if err != nil { 549 return nil, err 550 } 551 552 log.Debugf("Vote inv updated for block %v", blockHeight) 553 554 return inv, nil 555 } 556 557 var ( 558 // invKey is the key-value store key for the cached inventory. 559 invKey = "inv" 560 ) 561 562 // saveInv saves the inventory to the tstore cache. 563 func (c *invClient) saveInv(i inv) error { 564 b, err := json.Marshal(i) 565 if err != nil { 566 return err 567 } 568 return c.tstore.CachePut(map[string][]byte{invKey: b}, false) 569 } 570 571 // getInv returns the inventory from the tstore cache. A new inv is returned 572 // if one does not exist in the cache. 573 func (c *invClient) getInv() (*inv, error) { 574 blobs, err := c.tstore.CacheGet([]string{invKey}) 575 if err != nil { 576 return nil, err 577 } 578 b, ok := blobs[invKey] 579 if !ok { 580 // The inventory doesn't exist. Return a new one. 581 return newInv(), nil 582 } 583 var i inv 584 err = json.Unmarshal(b, &i) 585 if err != nil { 586 return nil, err 587 } 588 return &i, nil 589 } 590 591 // summary returns the vote summary for a record. 592 func (c *invClient) summary(token string) (*ticketvote.SummaryReply, error) { 593 tokenB, err := tokenDecode(token) 594 if err != nil { 595 return nil, err 596 } 597 reply, err := c.backend.PluginRead(tokenB, 598 ticketvote.PluginID, ticketvote.CmdSummary, "") 599 if err != nil { 600 return nil, err 601 } 602 var sr ticketvote.SummaryReply 603 err = json.Unmarshal([]byte(reply), &sr) 604 if err != nil { 605 return nil, err 606 } 607 return &sr, nil 608 } 609 610 // entriesIncludeToken returns whether the inventory entries include an entry 611 // that matches the provided token. 612 func entriesIncludeToken(entries []invEntry, token string) bool { 613 var found bool 614 for _, v := range entries { 615 if v.Token == token { 616 found = true 617 break 618 } 619 } 620 return found 621 } 622 623 // entryTokens filters and returns the tokens from the inventory entries. 624 func entryTokens(entries []invEntry) []string { 625 tokens := make([]string, 0, 2048) 626 for _, v := range entries { 627 tokens = append(tokens, v.Token) 628 } 629 return tokens 630 }