Skip to content
Snippets Groups Projects
Commit 3e654ff0 authored by Hans Moog's avatar Hans Moog
Browse files

Feat: CA consensus is working - started implementing test cases

parent 825a5dc3
No related branches found
No related tags found
No related merge requests found
......@@ -17,6 +17,7 @@ type NeighborManagerEvents struct {
NeighborIdle *events.Event
ChainReset *events.Event
StatementMissing *events.Event
UpdateOpinion *events.Event
}
type StatementChainEvents struct {
......@@ -30,3 +31,7 @@ func HashCaller(handler interface{}, params ...interface{}) {
func IdentityNeighborManagerCaller(handler interface{}, params ...interface{}) {
handler.(func(*identity.Identity, *NeighborManager))(params[0].(*identity.Identity), params[1].(*NeighborManager))
}
func StringBoolCaller(handler interface{}, params ...interface{}) {
handler.(func(string, bool))(params[0].(string), params[1].(bool))
}
......@@ -4,6 +4,8 @@ import (
"sync"
"time"
"github.com/iotaledger/goshimmer/packages/typeutils"
"github.com/iotaledger/goshimmer/packages/events"
"github.com/iotaledger/goshimmer/packages/identity"
......@@ -21,11 +23,10 @@ type HeartbeatManager struct {
statementChain *StatementChain
droppedNeighbors [][]byte
neighborManagers map[string]*NeighborManager
initialOpinions map[string]bool
opinions *OpinionRegister
droppedNeighborsMutex sync.RWMutex
neighborManagersMutex sync.RWMutex
initialOpinionsMutex sync.RWMutex
}
func NewHeartbeatManager(identity *identity.Identity, options ...HeartbeatManagerOption) *HeartbeatManager {
......@@ -41,7 +42,7 @@ func NewHeartbeatManager(identity *identity.Identity, options ...HeartbeatManage
statementChain: NewStatementChain(),
droppedNeighbors: make([][]byte, 0),
neighborManagers: make(map[string]*NeighborManager),
initialOpinions: make(map[string]bool),
opinions: NewOpinionRegister(),
}
}
......@@ -54,6 +55,10 @@ func (heartbeatManager *HeartbeatManager) AddNeighbor(neighborIdentity *identity
if _, exists := heartbeatManager.neighborManagers[neighborIdentity.StringIdentifier]; !exists {
newNeighborManager := NewNeighborManager()
newNeighborManager.Events.UpdateOpinion.Attach(events.NewClosure(func(transactionId string, liked bool) {
heartbeatManager.processNeighborOpinionUpdate(neighborIdentity, transactionId, liked)
}))
heartbeatManager.neighborManagers[neighborIdentity.StringIdentifier] = newNeighborManager
heartbeatManager.neighborManagersMutex.Unlock()
......@@ -90,15 +95,45 @@ func (heartbeatManager *HeartbeatManager) RemoveNeighbor(neighborIdentity *ident
}
func (heartbeatManager *HeartbeatManager) InitialDislike(transactionId []byte) {
heartbeatManager.initialOpinionsMutex.Lock()
heartbeatManager.initialOpinions[string(transactionId)] = false
heartbeatManager.initialOpinionsMutex.Unlock()
heartbeatManager.opinions.CreateOpinion(typeutils.BytesToString(transactionId)).SetLiked(false)
}
func (heartbeatManager *HeartbeatManager) InitialLike(transactionId []byte) {
heartbeatManager.initialOpinionsMutex.Lock()
heartbeatManager.initialOpinions[string(transactionId)] = true
heartbeatManager.initialOpinionsMutex.Unlock()
heartbeatManager.opinions.CreateOpinion(typeutils.BytesToString(transactionId)).SetLiked(true)
}
func (heartbeatManager *HeartbeatManager) processNeighborOpinionUpdate(neighbor *identity.Identity, transactionId string, liked bool) {
opinion := heartbeatManager.opinions.GetOpinion(transactionId)
if !opinion.Exists() || opinion.IsLiked() != liked {
totalWeight := len(heartbeatManager.neighborManagers)
threshold := float64(totalWeight) / 2
likedWeight := 0
dislikedWeight := 0
for _, neighborManager := range heartbeatManager.neighborManagers {
weightOfNeighbor := 1
if neighborOpinionLiked, exists := neighborManager.opinions[transactionId]; exists {
if neighborOpinionLiked {
likedWeight += weightOfNeighbor
} else {
dislikedWeight += weightOfNeighbor
}
}
}
if likedWeight > dislikedWeight && likedWeight > int(threshold) {
if !opinion.Exists() || !opinion.IsLiked() {
opinion = heartbeatManager.opinions.CreateOpinion(transactionId)
opinion.SetLiked(true)
}
} else if dislikedWeight >= likedWeight && dislikedWeight >= int(threshold) {
if !opinion.Exists() || opinion.IsLiked() {
opinion = heartbeatManager.opinions.CreateOpinion(transactionId)
opinion.SetLiked(false)
}
}
}
}
func (heartbeatManager *HeartbeatManager) GenerateHeartbeat() (result *heartbeat.Heartbeat, err errors.IdentifiableError) {
......@@ -169,7 +204,7 @@ func (heartbeatManager *HeartbeatManager) generateMainStatement() (result *heart
if signingErr := mainStatement.Sign(heartbeatManager.identity); signingErr == nil {
result = mainStatement
heartbeatManager.resetInitialOpinions()
heartbeatManager.opinions.ApplyPendingOpinions()
heartbeatManager.statementChain.tail = mainStatement
} else {
err = signingErr
......@@ -205,11 +240,11 @@ func (heartbeatManager *HeartbeatManager) generateNeighborStatements() (result m
func (heartbeatManager *HeartbeatManager) generateToggledTransactions() []*heartbeat.ToggledTransaction {
toggledTransactions := make([]*heartbeat.ToggledTransaction, 0)
for transactionId, liked := range heartbeatManager.initialOpinions {
if !liked {
for transactionId, opinion := range heartbeatManager.opinions.GetPendingOpinions() {
if !opinion.IsLiked() {
newToggledTransaction := heartbeat.NewToggledTransaction()
newToggledTransaction.SetInitialStatement(true)
newToggledTransaction.SetFinalStatement(false)
newToggledTransaction.SetInitialStatement(opinion.IsInitial())
newToggledTransaction.SetFinalStatement(opinion.IsFinalized())
newToggledTransaction.SetTransactionId([]byte(transactionId))
toggledTransactions = append(toggledTransactions, newToggledTransaction)
......@@ -218,7 +253,3 @@ func (heartbeatManager *HeartbeatManager) generateToggledTransactions() []*heart
return toggledTransactions
}
func (heartbeatManager *HeartbeatManager) resetInitialOpinions() {
heartbeatManager.initialOpinions = make(map[string]bool)
}
......@@ -4,6 +4,9 @@ import (
"crypto/rand"
"fmt"
"testing"
"time"
"github.com/iotaledger/goshimmer/packages/typeutils"
"github.com/iotaledger/goshimmer/packages/events"
......@@ -17,6 +20,69 @@ func generateRandomTransactionId() (result []byte) {
return
}
type virtualNode struct {
identity *identity.Identity
heartbeatManager *HeartbeatManager
}
func generateVirtualNetwork(numberOfNodes int) (result []*virtualNode) {
for i := 0; i < numberOfNodes; i++ {
nodeIdentity := identity.GenerateRandomIdentity()
virtualNode := &virtualNode{
identity: nodeIdentity,
heartbeatManager: NewHeartbeatManager(nodeIdentity),
}
result = append(result, virtualNode)
}
for i := 0; i < numberOfNodes; i++ {
for j := 0; j < numberOfNodes; j++ {
if i != j {
result[i].heartbeatManager.AddNeighbor(result[j].identity)
}
}
}
go func() {
for {
for _, node := range result {
heartbeat, err := node.heartbeatManager.GenerateHeartbeat()
if err != nil {
fmt.Println(err)
return
}
for _, otherNode := range result {
if otherNode != node {
otherNode.heartbeatManager.ApplyHeartbeat(heartbeat)
}
}
}
time.Sleep(700 * time.Millisecond)
}
}()
return
}
func TestConsensus(t *testing.T) {
virtualNetwork := generateVirtualNetwork(5)
transactionId := generateRandomTransactionId()
virtualNetwork[0].heartbeatManager.InitialDislike(transactionId)
virtualNetwork[1].heartbeatManager.InitialDislike(transactionId)
virtualNetwork[2].heartbeatManager.InitialDislike(transactionId)
time.Sleep(1 * time.Second)
fmt.Println(virtualNetwork[4].heartbeatManager.opinions.GetOpinion(typeutils.BytesToString(transactionId)).IsLiked())
}
func TestHeartbeatManager_GenerateHeartbeat(t *testing.T) {
ownIdentity := identity.GenerateRandomIdentity()
neighborIdentity := identity.GenerateRandomIdentity()
......@@ -31,7 +97,6 @@ func TestHeartbeatManager_GenerateHeartbeat(t *testing.T) {
heartbeatManager1.InitialDislike(generateRandomTransactionId())
heartbeatManager1.InitialDislike(generateRandomTransactionId())
heartbeatManager1.InitialLike(generateRandomTransactionId())
heartbeat1, err := heartbeatManager1.GenerateHeartbeat()
if err != nil {
t.Error(err)
......@@ -67,14 +132,14 @@ func TestHeartbeatManager_GenerateHeartbeat(t *testing.T) {
return
}
fmt.Println(heartbeat2)
if err = heartbeatManager1.ApplyHeartbeat(heartbeat2); err != nil {
t.Error(err)
return
}
fmt.Println(heartbeat2)
heartbeat3, err := heartbeatManager1.GenerateHeartbeat()
if err != nil {
t.Error(err)
......
......@@ -2,7 +2,6 @@ package ca
import (
"bytes"
"fmt"
"strconv"
"github.com/iotaledger/goshimmer/packages/typeutils"
......@@ -23,6 +22,7 @@ type NeighborManager struct {
heartbeats map[string]*heartbeat.Heartbeat
mainChain *StatementChain
neighborChains map[string]*StatementChain
opinions map[string]bool
previouslyReportedHeartbeatHash []byte
}
......@@ -34,15 +34,23 @@ func NewNeighborManager(options ...NeighborManagerOption) *NeighborManager {
}),
RemoveNeighbor: events.NewEvent(func(handler interface{}, params ...interface{}) {
}),
NeighborActive: events.NewEvent(func(handler interface{}, params ...interface{}) {
}),
NeighborIdle: events.NewEvent(func(handler interface{}, params ...interface{}) {
}),
ChainReset: events.NewEvent(events.CallbackCaller),
StatementMissing: events.NewEvent(HashCaller),
UpdateOpinion: events.NewEvent(StringBoolCaller),
},
options: DEFAULT_NEIGHBOR_MANAGER_OPTIONS.Override(options...),
mainChain: NewStatementChain(),
missingHeartbeats: make(map[string]bool),
pendingHeartbeats: make(map[string]*heartbeat.Heartbeat),
heartbeats: make(map[string]*heartbeat.Heartbeat),
opinions: make(map[string]bool),
neighborChains: make(map[string]*StatementChain),
}
}
......@@ -50,6 +58,7 @@ func NewNeighborManager(options ...NeighborManagerOption) *NeighborManager {
func (neighborManager *NeighborManager) Reset() {
neighborManager.mainChain.Reset()
neighborManager.neighborChains = make(map[string]*StatementChain)
neighborManager.opinions = make(map[string]bool)
}
func (neighborManager *NeighborManager) storeHeartbeat(heartbeat *heartbeat.Heartbeat) (err errors.IdentifiableError) {
......@@ -113,7 +122,7 @@ func (neighborManager *NeighborManager) applyPendingHeartbeats() (err errors.Ide
}
for _, sortedHeartbeat := range sortedPendingHeartbeats {
if applicationErr := neighborManager.applyHeartbeat(sortedHeartbeat); applicationErr != nil {
if applicationErr := neighborManager.applySolidHeartbeat(sortedHeartbeat); applicationErr != nil {
err = applicationErr
return
......@@ -180,22 +189,21 @@ func (neighborManager *NeighborManager) markIdleNeighbors(neighborStatements map
for neighborId := range neighborStatements {
if _, neighborExists := idleNeighbors[neighborId]; neighborExists {
// TRIGGER ACTIVE
neighborManager.Events.NeighborActive.Trigger(neighborId)
delete(idleNeighbors, neighborId)
}
}
for _, x := range idleNeighbors {
// TRIGGER IDLE
if false {
fmt.Println(x)
}
for neighborId := range idleNeighbors {
neighborManager.Events.NeighborIdle.Trigger(neighborId)
}
}
func (neighborManager *NeighborManager) updateStatementChains(mainStatement *heartbeat.OpinionStatement, neighborStatements map[string][]*heartbeat.OpinionStatement) (err errors.IdentifiableError) {
neighborManager.mainChain.AddStatement(mainStatement)
if err = neighborManager.mainChain.AddStatement(mainStatement); err != nil {
return
}
for neighborId, statementsOfNeighbor := range neighborStatements {
neighborChain, neighborChainErr := neighborManager.addNeighborChain(neighborId)
......@@ -206,24 +214,128 @@ func (neighborManager *NeighborManager) updateStatementChains(mainStatement *hea
}
for _, neighborStatement := range statementsOfNeighbor {
neighborChain.AddStatement(neighborStatement)
if statementErr := neighborChain.AddStatement(neighborStatement); statementErr != nil {
err = statementErr
return
}
}
}
return
}
func (neighborManager *NeighborManager) applyHeartbeat(heartbeat *heartbeat.Heartbeat) (err errors.IdentifiableError) {
func (neighborManager *NeighborManager) applySolidHeartbeat(heartbeat *heartbeat.Heartbeat) (err errors.IdentifiableError) {
mainStatement := heartbeat.GetMainStatement()
neighborStatements := heartbeat.GetNeighborStatements()
neighborManager.removeDroppedNeighbors(heartbeat.GetDroppedNeighbors())
neighborManager.markIdleNeighbors(neighborStatements)
if err = neighborManager.updateStatementChains(mainStatement, neighborStatements); err != nil {
return
}
if err = neighborManager.updateNeighborManager(); err != nil {
return
}
return
}
func (neighborManager *NeighborManager) updateNeighborManager() (err errors.IdentifiableError) {
updatedOpinions, verificationErr := neighborManager.retrieveAndVerifyUpdates()
if verificationErr != nil {
err = verificationErr
return
}
for transactionId, liked := range updatedOpinions {
if currentlyLiked, opinionExists := neighborManager.opinions[transactionId]; !opinionExists || currentlyLiked != liked {
neighborManager.opinions[transactionId] = liked
neighborManager.Events.UpdateOpinion.Trigger(transactionId, liked)
}
}
return
}
func (neighborManager *NeighborManager) retrieveAndVerifyUpdates() (updates map[string]bool, err errors.IdentifiableError) {
updates = make(map[string]bool)
// retrieve required parameters
totalWeight := len(neighborManager.neighborChains)
threshold := float64(totalWeight) / 2
opinionsOfNeighbors := neighborManager.getAccumulatedPendingOpinionsOfNeighbors()
mainChainInitialOpinions := make(map[string]*Opinion)
mainChainOpinionChanges := make(map[string]*Opinion)
for transactionId, opinion := range neighborManager.mainChain.GetOpinions().GetPendingOpinions() {
if opinion.IsInitial() {
mainChainInitialOpinions[transactionId] = opinion
} else {
mainChainOpinionChanges[transactionId] = opinion
}
}
// always consider initial opinions
for transactionId, opinion := range mainChainInitialOpinions {
updates[transactionId] = opinion.IsLiked()
}
// consider opinions that have seen enough neighbors
for transactionId, opinion := range opinionsOfNeighbors {
if opinion[0] > opinion[1] && opinion[0] > int(threshold) {
// main statement "should" like it
if changedOpinion := mainChainOpinionChanges[transactionId]; !changedOpinion.Exists() || !changedOpinion.IsLiked() {
if initialOpinion := mainChainInitialOpinions[transactionId]; !initialOpinion.Exists() || !initialOpinion.IsLiked() {
err = ErrMalformedHeartbeat.Derive("main statement should like transaction")
return
} else {
updates[transactionId] = true
}
} else {
updates[transactionId] = true
}
} else if opinion[1] > opinion[0] && opinion[1] >= int(threshold) {
// main statement "should" dislike it
if changedOpinion := mainChainOpinionChanges[transactionId]; !changedOpinion.Exists() || changedOpinion.IsLiked() {
if initialOpinion := mainChainInitialOpinions[transactionId]; !initialOpinion.Exists() || initialOpinion.IsLiked() {
err = ErrMalformedHeartbeat.Derive("main statement should dislike transaction")
return
} else {
updates[transactionId] = false
}
} else {
updates[transactionId] = false
}
}
}
return
}
func (neighborManager *NeighborManager) getAccumulatedPendingOpinionsOfNeighbors() (result map[string][]int) {
result = make(map[string][]int)
for _, neighborChain := range neighborManager.neighborChains {
for transactionId, pendingOpinion := range neighborChain.GetOpinions().GetPendingOpinions() {
opinion, exists := result[transactionId]
if !exists {
opinion = make([]int, 2)
result[transactionId] = opinion
}
weightOfNeighbor := 1
if pendingOpinion.IsLiked() {
opinion[0] += weightOfNeighbor
} else {
opinion[1] += weightOfNeighbor
}
}
}
return
}
......
package ca
import (
"sync"
)
type Opinion struct {
initial bool
liked bool
finalized bool
pending bool
initialMutex sync.RWMutex
likedMutex sync.RWMutex
finalizedMutex sync.RWMutex
pendingMutex sync.RWMutex
}
func NewOpinion() *Opinion {
return &Opinion{}
}
func (opinion *Opinion) IsLiked() bool {
opinion.likedMutex.RLock()
defer opinion.likedMutex.RUnlock()
return opinion.liked
}
func (opinion *Opinion) SetLiked(liked bool) *Opinion {
opinion.likedMutex.Lock()
defer opinion.likedMutex.Unlock()
opinion.liked = liked
return opinion
}
func (opinion *Opinion) IsInitial() bool {
opinion.initialMutex.RLock()
defer opinion.initialMutex.RLock()
return opinion.initial
}
func (opinion *Opinion) SetInitial(initial bool) *Opinion {
opinion.initialMutex.Lock()
defer opinion.initialMutex.Unlock()
opinion.initial = initial
return opinion
}
func (opinion *Opinion) IsFinalized() bool {
opinion.finalizedMutex.RLock()
defer opinion.finalizedMutex.RLock()
return opinion.finalized
}
func (opinion *Opinion) SetFinalized(finalized bool) *Opinion {
opinion.finalizedMutex.Lock()
defer opinion.finalizedMutex.Unlock()
opinion.finalized = finalized
return opinion
}
func (opinion *Opinion) IsPending() bool {
opinion.pendingMutex.RLock()
defer opinion.pendingMutex.RUnlock()
return opinion.pending
}
func (opinion *Opinion) SetPending(pending bool) *Opinion {
opinion.pendingMutex.Lock()
defer opinion.pendingMutex.Unlock()
opinion.pending = pending
return opinion
}
func (opinion *Opinion) Exists() bool {
return opinion != nil
}
package ca
type OpinionRegister struct {
pendingOpinions map[string]*Opinion
appliedOpinions map[string]*Opinion
}
func NewOpinionRegister() *OpinionRegister {
return &OpinionRegister{
pendingOpinions: make(map[string]*Opinion),
appliedOpinions: make(map[string]*Opinion),
}
}
func (opinionRegister *OpinionRegister) GetPendingOpinions() map[string]*Opinion {
return opinionRegister.pendingOpinions
}
func (opinionRegister *OpinionRegister) GetAppliedOpinions() map[string]*Opinion {
return opinionRegister.appliedOpinions
}
func (opinionRegister *OpinionRegister) GetOpinion(transactionId string) (opinion *Opinion) {
if changedOpinion := opinionRegister.pendingOpinions[transactionId]; changedOpinion.Exists() {
opinion = changedOpinion
} else {
opinion = opinionRegister.appliedOpinions[transactionId]
}
return
}
func (opinionRegister *OpinionRegister) CreateOpinion(transactionId string) (opinion *Opinion) {
if opinion = opinionRegister.GetOpinion(transactionId); opinion.Exists() && opinion.IsPending() {
return
}
opinion = NewOpinion()
opinion.SetInitial(true)
opinion.SetPending(true)
opinionRegister.pendingOpinions[transactionId] = opinion
return opinion
}
func (opinionRegister *OpinionRegister) ApplyPendingOpinions() {
for transactionId, opinion := range opinionRegister.pendingOpinions {
opinion.SetPending(false)
opinionRegister.appliedOpinions[transactionId] = opinion
}
opinionRegister.pendingOpinions = make(map[string]*Opinion)
}
package ca
import (
"fmt"
"testing"
)
func TestOpinionRegister_GetOpinion(t *testing.T) {
opinionRegister := NewOpinionRegister()
x := opinionRegister.CreateOpinion("ABC")
fmt.Println(x.Exists())
fmt.Println(opinionRegister.GetOpinion("ABC").Exists())
}
......@@ -15,10 +15,9 @@ import (
type StatementChain struct {
Events StatementChainEvents
pendingTransactionStatuses map[string]bool
transactionStatuses map[string]bool
statements map[string]*heartbeat.OpinionStatement
tail *heartbeat.OpinionStatement
opinions *OpinionRegister
statements map[string]*heartbeat.OpinionStatement
tail *heartbeat.OpinionStatement
}
func NewStatementChain() *StatementChain {
......@@ -26,22 +25,11 @@ func NewStatementChain() *StatementChain {
Events: StatementChainEvents{
Reset: events.NewEvent(events.CallbackCaller),
},
pendingTransactionStatuses: make(map[string]bool),
transactionStatuses: make(map[string]bool),
statements: make(map[string]*heartbeat.OpinionStatement),
opinions: NewOpinionRegister(),
statements: make(map[string]*heartbeat.OpinionStatement),
}
}
func (statementChain *StatementChain) getTransactionStatus(transactionId string) (result bool, exists bool) {
if result, exists = statementChain.pendingTransactionStatuses[transactionId]; exists {
return
}
result, exists = statementChain.transactionStatuses[transactionId]
return
}
func (statementChain *StatementChain) AddStatement(statement *heartbeat.OpinionStatement) errors.IdentifiableError {
previousStatementHash := statement.GetPreviousStatementHash()
lastAppliedStatement := statementChain.tail
......@@ -54,16 +42,25 @@ func (statementChain *StatementChain) AddStatement(statement *heartbeat.OpinionS
transactionId := typeutils.BytesToString(toggledTransaction.GetTransactionId())
if toggledTransaction.IsInitialStatement() {
if _, exists := statementChain.getTransactionStatus(transactionId); exists {
opinion := statementChain.opinions.GetOpinion(transactionId)
if opinion.Exists() {
return ErrMalformedHeartbeat.Derive("two initial statements for the same transaction")
}
statementChain.pendingTransactionStatuses[transactionId] = false
statementChain.opinions.CreateOpinion(transactionId).SetLiked(false)
} else if toggledTransaction.IsFinalStatement() {
// finalize -> clean up
} else {
if currentValue, exists := statementChain.getTransactionStatus(transactionId); exists {
statementChain.pendingTransactionStatuses[transactionId] = !currentValue
opinion := statementChain.opinions.GetOpinion(transactionId)
if opinion.Exists() {
if opinion.IsPending() {
return ErrMalformedHeartbeat.Derive("two changed statements for the same transaction")
}
opinion.SetInitial(false)
opinion.SetPending(true)
statementChain.opinions.pendingOpinions[transactionId] = opinion
}
}
}
......@@ -75,11 +72,7 @@ func (statementChain *StatementChain) AddStatement(statement *heartbeat.OpinionS
}
func (statementChain *StatementChain) ApplyPendingTransactionStatusChanges() {
for transactionId, value := range statementChain.pendingTransactionStatuses {
statementChain.transactionStatuses[transactionId] = value
}
statementChain.pendingTransactionStatuses = make(map[string]bool)
statementChain.opinions.ApplyPendingOpinions()
}
func (statementChain *StatementChain) GetStatement(statementHash []byte) *heartbeat.OpinionStatement {
......@@ -87,6 +80,7 @@ func (statementChain *StatementChain) GetStatement(statementHash []byte) *heartb
}
func (statementChain *StatementChain) Reset() {
statementChain.opinions = NewOpinionRegister()
statementChain.statements = make(map[string]*heartbeat.OpinionStatement)
statementChain.tail = nil
......@@ -96,3 +90,7 @@ func (statementChain *StatementChain) Reset() {
func (statementChain *StatementChain) GetTail() *heartbeat.OpinionStatement {
return statementChain.tail
}
func (statementChain *StatementChain) GetOpinions() *OpinionRegister {
return statementChain.opinions
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment