Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pi: Cache proposal statuses. #1586

Merged
merged 45 commits into from Dec 3, 2021
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
45084c4
[wip] pi: cache proposal statuses.
amass01 Nov 24, 2021
e9a7af7
cleanup & lint
amass01 Nov 26, 2021
56ce553
finalize proposal status caching
amass01 Nov 27, 2021
20b3e53
fix list of cacheable proposal statuses
amass01 Nov 27, 2021
9fa9827
cache vote status to avoid extra tlog tree read
amass01 Nov 28, 2021
7339925
typo
amass01 Nov 28, 2021
b8375ef
apply @lukebp suggestions
amass01 Nov 29, 2021
481ee3b
doc
amass01 Nov 29, 2021
b59bf09
doc
amass01 Nov 29, 2021
e8cf4c0
cleanup
amass01 Nov 29, 2021
4220db0
doc limit
amass01 Nov 29, 2021
3b10bde
cleanup
amass01 Nov 29, 2021
911ea00
more code review nits
amass01 Nov 29, 2021
c7e0bdb
improve logic paths
amass01 Nov 29, 2021
59bd598
one logic path to determine & cache status
amass01 Nov 29, 2021
24cf649
fix filenames
amass01 Nov 29, 2021
424f054
whoops
amass01 Nov 29, 2021
1b67f1d
cleanup
amass01 Nov 29, 2021
a3ab88c
simplify & cleanup for good
amass01 Nov 29, 2021
bdca766
tests cmdSummary command for final statuses
amass01 Nov 30, 2021
7582bc3
test cache get func
amass01 Nov 30, 2021
fb885ed
whooops
amass01 Nov 30, 2021
b39ef6c
add proposalStatuses.set unit tests
amass01 Dec 2, 2021
f5ee062
typos
amass01 Dec 2, 2021
350a711
cache capacity limit as a global var
amass01 Dec 2, 2021
7c20f2a
doc
amass01 Dec 2, 2021
b36738f
cleanup
amass01 Dec 2, 2021
6b4bd49
doc
amass01 Dec 2, 2021
d630c1a
more doc
amass01 Dec 2, 2021
fcf4b6f
code review nits
amass01 Dec 3, 2021
549973b
use slice of tests instead of array
amass01 Dec 3, 2021
5bb46c5
table driven tests for cache getter
amass01 Dec 3, 2021
74d74b5
Tweak logic. (#4)
lukebp Dec 3, 2021
0bf0ae3
typo
amass01 Dec 3, 2021
dc88536
don't fetch vote summary if cached vote status is final
amass01 Dec 3, 2021
75f497f
typo
amass01 Dec 3, 2021
172337c
cleanup setter tests & add overwrite test case
amass01 Dec 3, 2021
a53cdb4
the
amass01 Dec 3, 2021
ad6d77e
unique tokens in getter test
amass01 Dec 3, 2021
a1f12d4
cleanup
amass01 Dec 3, 2021
dcf06de
Move TestProposalStatus
amass01 Dec 3, 2021
d00d8f3
last couple of touches
amass01 Dec 3, 2021
6588101
.
amass01 Dec 3, 2021
f1a48fc
add debug statements to proposalStatuses.set
amass01 Dec 3, 2021
f4dc4ae
init pi plugin log
amass01 Dec 3, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
54 changes: 5 additions & 49 deletions politeiad/backendv2/tstorebe/plugins/pi/cmds.go
Expand Up @@ -244,63 +244,19 @@ func (p *piPlugin) cmdBillingStatusChanges(token []byte) (string, error) {

// cmdSummary returns the pi summary of a proposal.
amass01 marked this conversation as resolved.
Show resolved Hide resolved
func (p *piPlugin) cmdSummary(token []byte) (string, error) {
lukebp marked this conversation as resolved.
Show resolved Hide resolved
amass01 marked this conversation as resolved.
Show resolved Hide resolved
// Get an abridged version of the record. We only
// need the record metadata and the vote metadata.
r, err := p.record(backend.RecordRequest{
Token: token,
Filenames: []string{ticketvote.FileNameVoteMetadata},
})
if err != nil {
return "", err
}
var (
mdState = r.RecordMetadata.State
mdStatus = r.RecordMetadata.Status
voteStatus = ticketvote.VoteStatusInvalid

voteMD *ticketvote.VoteMetadata
bscs []pi.BillingStatusChange
)

// Pull the vote metadata out of the record files.
voteMD, err = voteMetadataDecode(r.Files)
if err != nil {
return "", err
}

// Fetch vote status & billing status change if they are needed in order
// to determine the proposal status.
if mdState == backend.StateVetted {
// If proposal status is public fetch vote status.
if mdStatus == backend.StatusPublic {
vs, err := p.voteSummary(token)
if err != nil {
return "", err
}
voteStatus = vs.Status
// If vote status is approved fetch billing status change.
if voteStatus == ticketvote.VoteStatusApproved {
bscs, err = p.billingStatusChanges(token)
if err != nil {
return "", err
}
}
}
}

// Determine the proposal status
proposalStatus, err := proposalStatus(mdState, mdStatus,
voteStatus, voteMD, bscs)
// Get the proposal status
propStatus, err := p.getProposalStatus(token)
if err != nil {
return "", err
}

// Prepare reply
// Prepare the reply
sr := pi.SummaryReply{
Summary: pi.ProposalSummary{
Status: proposalStatus,
Status: propStatus,
},
}

reply, err := json.Marshal(sr)
if err != nil {
return "", err
Expand Down
69 changes: 69 additions & 0 deletions politeiad/backendv2/tstorebe/plugins/pi/cmds_test.go
Expand Up @@ -241,6 +241,75 @@ func TestCmdBillingStatus(t *testing.T) {
}
}

func TestCmdSummary(t *testing.T) {
// Setup pi plugin
p, cleanup := newTestPiPlugin(t)
defer cleanup()

// Setup test data
var tokens = [6]string{"45154fb45664714b", "45154fb45664714a",
"45154fb45664714c", "45154fb45664714d", "45154fb45664714e",
"45154fb45664714f"}
// List of all final proposal statuses
var statuses = [6]pi.PropStatusT{pi.PropStatusUnvettedAbandoned,
pi.PropStatusUnvettedCensored, pi.PropStatusAbandoned,
pi.PropStatusCensored, pi.PropStatusApproved, pi.PropStatusRejected}

// Setup tests and pre-load final statuses in cache
type test struct {
name string // Test name
token []byte
propStatus pi.PropStatusT // expected proposal status
}
var tests [6]test
amass01 marked this conversation as resolved.
Show resolved Hide resolved

for i, token := range tokens {
amass01 marked this conversation as resolved.
Show resolved Hide resolved
// Decode string token
b, err := hex.DecodeString(token)
if err != nil {
t.Fatal(err)
}

// Cache final status
p.statuses.set(token, statusEntry{
propStatus: statuses[i],
})

// Add test
tests[i] = test{
name: string(statuses[i]),
token: b,
propStatus: statuses[i],
}
}

// Run tests
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Run test
r, err := p.cmdSummary(tc.token)
if err != nil {
// Unexpected error
t.Fatal(err)
}

// Unmarshal command reply
var sr pi.SummaryReply
err = json.Unmarshal([]byte(r), &sr)
if err != nil {
t.Fatal(err)
}

// Check if received proposal status euqal to the expected.
if sr.Summary.Status != tc.propStatus {
t.Errorf("want proposal status %v, got '%v'", tc.propStatus,
sr.Summary.Status)
}
})
}

}

func TestProposalStatus(t *testing.T) {
// Setup tests
var tests = []struct {
Expand Down
10 changes: 10 additions & 0 deletions politeiad/backendv2/tstorebe/plugins/pi/pi.go
Expand Up @@ -5,6 +5,7 @@
package pi

import (
"container/list"
"encoding/json"
"os"
"path/filepath"
Expand Down Expand Up @@ -32,6 +33,10 @@ type piPlugin struct {
backend backend.Backend
tstore plugins.TstoreClient

// statuses holds proposal statuses in memory to avoid extra expensive
// tlog tree reads in runtime.
statuses proposalStatuses

// dataDir is the pi plugin data directory. The only data that is
// stored here is cached data that can be re-created at any time
// by walking the trillian trees.
Expand Down Expand Up @@ -339,5 +344,10 @@ func New(backend backend.Backend, tstore plugins.TstoreClient, settings []backen
proposalDomainsEncoded: domainsString,
proposalDomains: domainsMap,
billingStatusChangesMax: billingStatusChangesMax,
statuses: proposalStatuses{
data: make(map[string]*statusEntry, defaultCacheLimit),
entries: list.New(),
limit: defaultCacheLimit,
},
}, nil
}
189 changes: 189 additions & 0 deletions politeiad/backendv2/tstorebe/plugins/pi/proposalstatus.go
@@ -0,0 +1,189 @@
// Copyright (c) 2021 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.

package pi

import (
"encoding/hex"

backend "github.com/decred/politeia/politeiad/backendv2"
"github.com/decred/politeia/politeiad/plugins/pi"
"github.com/decred/politeia/politeiad/plugins/ticketvote"
)

// getProposalStatus determines the proposal status in runtime, it uses the
amass01 marked this conversation as resolved.
Show resolved Hide resolved
// in-memory cache to avoid retrieving the record, it's vote summary or
// it's billing status changes when possible.
func (p *piPlugin) getProposalStatus(token []byte) (pi.PropStatusT, error) {
var (
propStatus pi.PropStatusT
err error
tokenStr = hex.EncodeToString(token)

// The following fields are required to determine the proposal status and
// MUST be populated.
recordState backend.StateT
recordStatus backend.StatusT
voteStatus ticketvote.VoteStatusT

// The following fields are required to determine the proposal status and
// will only be populated for certain types of proposals or during certain
// stages of the proposal lifecycle.
voteMetadata *ticketvote.VoteMetadata
billingStatuses []pi.BillingStatusChange
)

// Check if the proposal status has been cached
e := p.statuses.get(tokenStr)
if e != nil {
propStatus = e.propStatus
recordState = e.recordState
recordStatus = e.recordStatus
voteStatus = e.voteStatus
voteMetadata = e.voteMetadata
}

// Check if we need to get any additional data
if statusIsFinal(propStatus) {
// The status is final and cannot be changed.
// No need to get any additional data.
return propStatus, nil
}

// Get the record if required
if statusRequiresRecord(propStatus) {
r, err := p.record(backend.RecordRequest{
Token: token,
Filenames: []string{ticketvote.FileNameVoteMetadata},
})
if err != nil {
return "", err
}

// Update the record data fields required to
// determine the proposal status.
recordState = r.RecordMetadata.State
recordStatus = r.RecordMetadata.Status
voteStatus = ticketvote.VoteStatusInvalid

// Pull the vote metadata out of the record files
voteMetadata, err = voteMetadataDecode(r.Files)
if err != nil {
return "", err
}
}

// Get the vote summary if required
if statusRequiresVoteSummary(propStatus) {
vs, err := p.voteSummary(token)
if err != nil {
return "", err
}
voteStatus = vs.Status
}

// Get the billing statuses if required
if statusRequiresBillingStatuses(voteStatus, voteMetadata) {
billingStatuses, err = p.billingStatusChanges(token)
if err != nil {
return "", err
}
}

// Determine the proposal status
propStatus, err = proposalStatus(recordState, recordStatus, voteStatus,
voteMetadata, billingStatuses)
if err != nil {
return "", nil
}

// Cache the results
p.statuses.set(tokenStr, statusEntry{
propStatus: propStatus,
recordState: recordState,
recordStatus: recordStatus,
voteStatus: voteStatus,
voteMetadata: voteMetadata,
})

return propStatus, nil
}

// statusIsFinal returns whether the proposal status is a final status and
// cannot be changed any further.
func statusIsFinal(s pi.PropStatusT) bool {
switch s {
case pi.PropStatusUnvettedAbandoned, pi.PropStatusUnvettedCensored,
pi.PropStatusAbandoned, pi.PropStatusCensored, pi.PropStatusApproved,
pi.PropStatusRejected:
return true
default:
return false
}
}

// statusRequiresRecord returns whether the proposal status requires the record
// to be retrieved from the backend. This is necessary when the proposal is in
// a part of the proposal lifecycle that still allows changes to the underlying
// record data. For example, an unvetted proposal may still have it's record
// metadata or vote metadata altered, but a proposal with the status of active
// cannot.
func statusRequiresRecord(s pi.PropStatusT) bool {
if statusIsFinal(s) {
// The status is final and cannot be changed
// any further, which means the record data
// is not required.
return false
}

switch s {
case pi.PropStatusVoteStarted, pi.PropStatusActive,
pi.PropStatusCompleted, pi.PropStatusClosed:
// The record cannot be changed any further for
// these statuses.
return false

case pi.PropStatusUnvetted, pi.PropStatusUnderReview,
pi.PropStatusVoteAuthorized:
// The record can still change for these statuses.
return true

default:
// Defaulting to true is the conservative default
// since it will force the record to be retrieved
// for unhandled cases.
return true
}
}

// statusRequiresVoteSummary returns whether the proposal status requires the
// vote summary to be retrieved. This is necessary when the proposal is in
// a stage where the vote status can still change.
func statusRequiresVoteSummary(s pi.PropStatusT) bool {
if statusIsFinal(s) {
// The status is final and cannot be changed
// any further, which means the vote summary
// is not required.
return false
}

switch s {
case pi.PropStatusActive, pi.PropStatusClosed, pi.PropStatusCompleted:
// The vote result is known no need to fetch
amass01 marked this conversation as resolved.
Show resolved Hide resolved
return false

default:
// If proposal status is unknown, not final or vote was not finished yet,
amass01 marked this conversation as resolved.
Show resolved Hide resolved
// we need to fetch the vote summary.
return true
}
}

// statusRequiresBillingStatuses returns whether the proposal status requires
// the billing status changes to be retrieved. The billing status
// changes are required only if the proposal is not a RFP and it's vote was
// approved, otherwise they are not relevant.
func statusRequiresBillingStatuses(vs ticketvote.VoteStatusT, vm *ticketvote.VoteMetadata) bool {
return !isRFP(vm) && vs == ticketvote.VoteStatusApproved
}