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  }