516 lines
16 KiB
Go
516 lines
16 KiB
Go
|
// Copyright 2020 The go-ethereum Authors
|
||
|
// This file is part of the go-ethereum library.
|
||
|
//
|
||
|
// The go-ethereum library is free software: you can redistribute it and/or modify
|
||
|
// it under the terms of the GNU Lesser General Public License as published by
|
||
|
// the Free Software Foundation, either version 3 of the License, or
|
||
|
// (at your option) any later version.
|
||
|
//
|
||
|
// The go-ethereum library is distributed in the hope that it will be useful,
|
||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
|
// GNU Lesser General Public License for more details.
|
||
|
//
|
||
|
// You should have received a copy of the GNU Lesser General Public License
|
||
|
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
|
||
|
|
||
|
package client
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"fmt"
|
||
|
"math"
|
||
|
"sync"
|
||
|
"time"
|
||
|
|
||
|
"github.com/ethereum/go-ethereum/common/mclock"
|
||
|
"github.com/ethereum/go-ethereum/ethdb"
|
||
|
"github.com/ethereum/go-ethereum/les/utils"
|
||
|
"github.com/ethereum/go-ethereum/log"
|
||
|
"github.com/ethereum/go-ethereum/p2p/enode"
|
||
|
"github.com/ethereum/go-ethereum/rlp"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
vtVersion = 1 // database encoding format for ValueTracker
|
||
|
nvtVersion = 1 // database encoding format for NodeValueTracker
|
||
|
)
|
||
|
|
||
|
var (
|
||
|
vtKey = []byte("vt:")
|
||
|
vtNodeKey = []byte("vtNode:")
|
||
|
)
|
||
|
|
||
|
// NodeValueTracker collects service value statistics for a specific server node
|
||
|
type NodeValueTracker struct {
|
||
|
lock sync.Mutex
|
||
|
|
||
|
rtStats, lastRtStats ResponseTimeStats
|
||
|
lastTransfer mclock.AbsTime
|
||
|
basket serverBasket
|
||
|
reqCosts []uint64
|
||
|
reqValues *[]float64
|
||
|
}
|
||
|
|
||
|
// init initializes a NodeValueTracker.
|
||
|
// Note that the contents of the referenced reqValues slice will not change; a new
|
||
|
// reference is passed if the values are updated by ValueTracker.
|
||
|
func (nv *NodeValueTracker) init(now mclock.AbsTime, reqValues *[]float64) {
|
||
|
reqTypeCount := len(*reqValues)
|
||
|
nv.reqCosts = make([]uint64, reqTypeCount)
|
||
|
nv.lastTransfer = now
|
||
|
nv.reqValues = reqValues
|
||
|
nv.basket.init(reqTypeCount)
|
||
|
}
|
||
|
|
||
|
// updateCosts updates the request cost table of the server. The request value factor
|
||
|
// is also updated based on the given cost table and the current reference basket.
|
||
|
// Note that the contents of the referenced reqValues slice will not change; a new
|
||
|
// reference is passed if the values are updated by ValueTracker.
|
||
|
func (nv *NodeValueTracker) updateCosts(reqCosts []uint64, reqValues *[]float64, rvFactor float64) {
|
||
|
nv.lock.Lock()
|
||
|
defer nv.lock.Unlock()
|
||
|
|
||
|
nv.reqCosts = reqCosts
|
||
|
nv.reqValues = reqValues
|
||
|
nv.basket.updateRvFactor(rvFactor)
|
||
|
}
|
||
|
|
||
|
// transferStats returns request basket and response time statistics that should be
|
||
|
// added to the global statistics. The contents of the server's own request basket are
|
||
|
// gradually transferred to the main reference basket and removed from the server basket
|
||
|
// with the specified transfer rate.
|
||
|
// The response time statistics are retained at both places and therefore the global
|
||
|
// distribution is always the sum of the individual server distributions.
|
||
|
func (nv *NodeValueTracker) transferStats(now mclock.AbsTime, transferRate float64) (requestBasket, ResponseTimeStats) {
|
||
|
nv.lock.Lock()
|
||
|
defer nv.lock.Unlock()
|
||
|
|
||
|
dt := now - nv.lastTransfer
|
||
|
nv.lastTransfer = now
|
||
|
if dt < 0 {
|
||
|
dt = 0
|
||
|
}
|
||
|
recentRtStats := nv.rtStats
|
||
|
recentRtStats.SubStats(&nv.lastRtStats)
|
||
|
nv.lastRtStats = nv.rtStats
|
||
|
return nv.basket.transfer(-math.Expm1(-transferRate * float64(dt))), recentRtStats
|
||
|
}
|
||
|
|
||
|
// RtStats returns the node's own response time distribution statistics
|
||
|
func (nv *NodeValueTracker) RtStats() ResponseTimeStats {
|
||
|
nv.lock.Lock()
|
||
|
defer nv.lock.Unlock()
|
||
|
|
||
|
return nv.rtStats
|
||
|
}
|
||
|
|
||
|
// ValueTracker coordinates service value calculation for individual servers and updates
|
||
|
// global statistics
|
||
|
type ValueTracker struct {
|
||
|
clock mclock.Clock
|
||
|
lock sync.Mutex
|
||
|
quit chan chan struct{}
|
||
|
db ethdb.KeyValueStore
|
||
|
connected map[enode.ID]*NodeValueTracker
|
||
|
reqTypeCount int
|
||
|
|
||
|
refBasket referenceBasket
|
||
|
mappings [][]string
|
||
|
currentMapping int
|
||
|
initRefBasket requestBasket
|
||
|
rtStats ResponseTimeStats
|
||
|
|
||
|
transferRate float64
|
||
|
statsExpLock sync.RWMutex
|
||
|
statsExpRate, offlineExpRate float64
|
||
|
statsExpirer utils.Expirer
|
||
|
statsExpFactor utils.ExpirationFactor
|
||
|
}
|
||
|
|
||
|
type valueTrackerEncV1 struct {
|
||
|
Mappings [][]string
|
||
|
RefBasketMapping uint
|
||
|
RefBasket requestBasket
|
||
|
RtStats ResponseTimeStats
|
||
|
ExpOffset, SavedAt uint64
|
||
|
}
|
||
|
|
||
|
type nodeValueTrackerEncV1 struct {
|
||
|
RtStats ResponseTimeStats
|
||
|
ServerBasketMapping uint
|
||
|
ServerBasket requestBasket
|
||
|
}
|
||
|
|
||
|
// RequestInfo is an initializer structure for the service vector.
|
||
|
type RequestInfo struct {
|
||
|
// Name identifies the request type and is used for re-mapping the service vector if necessary
|
||
|
Name string
|
||
|
// InitAmount and InitValue are used to initialize the reference basket
|
||
|
InitAmount, InitValue float64
|
||
|
}
|
||
|
|
||
|
// NewValueTracker creates a new ValueTracker and loads its previously saved state from
|
||
|
// the database if possible.
|
||
|
func NewValueTracker(db ethdb.KeyValueStore, clock mclock.Clock, reqInfo []RequestInfo, updatePeriod time.Duration, transferRate, statsExpRate, offlineExpRate float64) *ValueTracker {
|
||
|
now := clock.Now()
|
||
|
|
||
|
initRefBasket := requestBasket{items: make([]basketItem, len(reqInfo))}
|
||
|
mapping := make([]string, len(reqInfo))
|
||
|
|
||
|
var sumAmount, sumValue float64
|
||
|
for _, req := range reqInfo {
|
||
|
sumAmount += req.InitAmount
|
||
|
sumValue += req.InitAmount * req.InitValue
|
||
|
}
|
||
|
scaleValues := sumAmount * basketFactor / sumValue
|
||
|
for i, req := range reqInfo {
|
||
|
mapping[i] = req.Name
|
||
|
initRefBasket.items[i].amount = uint64(req.InitAmount * basketFactor)
|
||
|
initRefBasket.items[i].value = uint64(req.InitAmount * req.InitValue * scaleValues)
|
||
|
}
|
||
|
|
||
|
vt := &ValueTracker{
|
||
|
clock: clock,
|
||
|
connected: make(map[enode.ID]*NodeValueTracker),
|
||
|
quit: make(chan chan struct{}),
|
||
|
db: db,
|
||
|
reqTypeCount: len(initRefBasket.items),
|
||
|
initRefBasket: initRefBasket,
|
||
|
transferRate: transferRate,
|
||
|
statsExpRate: statsExpRate,
|
||
|
offlineExpRate: offlineExpRate,
|
||
|
}
|
||
|
if vt.loadFromDb(mapping) != nil {
|
||
|
// previous state not saved or invalid, init with default values
|
||
|
vt.refBasket.basket = initRefBasket
|
||
|
vt.mappings = [][]string{mapping}
|
||
|
vt.currentMapping = 0
|
||
|
}
|
||
|
vt.statsExpirer.SetRate(now, statsExpRate)
|
||
|
vt.refBasket.init(vt.reqTypeCount)
|
||
|
vt.periodicUpdate()
|
||
|
|
||
|
go func() {
|
||
|
for {
|
||
|
select {
|
||
|
case <-clock.After(updatePeriod):
|
||
|
vt.lock.Lock()
|
||
|
vt.periodicUpdate()
|
||
|
vt.lock.Unlock()
|
||
|
case quit := <-vt.quit:
|
||
|
close(quit)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
}()
|
||
|
return vt
|
||
|
}
|
||
|
|
||
|
// StatsExpirer returns the statistics expirer so that other values can be expired
|
||
|
// with the same rate as the service value statistics.
|
||
|
func (vt *ValueTracker) StatsExpirer() *utils.Expirer {
|
||
|
return &vt.statsExpirer
|
||
|
}
|
||
|
|
||
|
// loadFromDb loads the value tracker's state from the database and converts saved
|
||
|
// request basket index mapping if it does not match the specified index to name mapping.
|
||
|
func (vt *ValueTracker) loadFromDb(mapping []string) error {
|
||
|
enc, err := vt.db.Get(vtKey)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
r := bytes.NewReader(enc)
|
||
|
var version uint
|
||
|
if err := rlp.Decode(r, &version); err != nil {
|
||
|
log.Error("Decoding value tracker state failed", "err", err)
|
||
|
return err
|
||
|
}
|
||
|
if version != vtVersion {
|
||
|
log.Error("Unknown ValueTracker version", "stored", version, "current", nvtVersion)
|
||
|
return fmt.Errorf("Unknown ValueTracker version %d (current version is %d)", version, vtVersion)
|
||
|
}
|
||
|
var vte valueTrackerEncV1
|
||
|
if err := rlp.Decode(r, &vte); err != nil {
|
||
|
log.Error("Decoding value tracker state failed", "err", err)
|
||
|
return err
|
||
|
}
|
||
|
logOffset := utils.Fixed64(vte.ExpOffset)
|
||
|
dt := time.Now().UnixNano() - int64(vte.SavedAt)
|
||
|
if dt > 0 {
|
||
|
logOffset += utils.Float64ToFixed64(float64(dt) * vt.offlineExpRate / math.Log(2))
|
||
|
}
|
||
|
vt.statsExpirer.SetLogOffset(vt.clock.Now(), logOffset)
|
||
|
vt.rtStats = vte.RtStats
|
||
|
vt.mappings = vte.Mappings
|
||
|
vt.currentMapping = -1
|
||
|
loop:
|
||
|
for i, m := range vt.mappings {
|
||
|
if len(m) != len(mapping) {
|
||
|
continue loop
|
||
|
}
|
||
|
for j, s := range mapping {
|
||
|
if m[j] != s {
|
||
|
continue loop
|
||
|
}
|
||
|
}
|
||
|
vt.currentMapping = i
|
||
|
break
|
||
|
}
|
||
|
if vt.currentMapping == -1 {
|
||
|
vt.currentMapping = len(vt.mappings)
|
||
|
vt.mappings = append(vt.mappings, mapping)
|
||
|
}
|
||
|
if int(vte.RefBasketMapping) == vt.currentMapping {
|
||
|
vt.refBasket.basket = vte.RefBasket
|
||
|
} else {
|
||
|
if vte.RefBasketMapping >= uint(len(vt.mappings)) {
|
||
|
log.Error("Unknown request basket mapping", "stored", vte.RefBasketMapping, "current", vt.currentMapping)
|
||
|
return fmt.Errorf("Unknown request basket mapping %d (current version is %d)", vte.RefBasketMapping, vt.currentMapping)
|
||
|
}
|
||
|
vt.refBasket.basket = vte.RefBasket.convertMapping(vt.mappings[vte.RefBasketMapping], mapping, vt.initRefBasket)
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// saveToDb saves the value tracker's state to the database
|
||
|
func (vt *ValueTracker) saveToDb() {
|
||
|
vte := valueTrackerEncV1{
|
||
|
Mappings: vt.mappings,
|
||
|
RefBasketMapping: uint(vt.currentMapping),
|
||
|
RefBasket: vt.refBasket.basket,
|
||
|
RtStats: vt.rtStats,
|
||
|
ExpOffset: uint64(vt.statsExpirer.LogOffset(vt.clock.Now())),
|
||
|
SavedAt: uint64(time.Now().UnixNano()),
|
||
|
}
|
||
|
enc1, err := rlp.EncodeToBytes(uint(vtVersion))
|
||
|
if err != nil {
|
||
|
log.Error("Encoding value tracker state failed", "err", err)
|
||
|
return
|
||
|
}
|
||
|
enc2, err := rlp.EncodeToBytes(&vte)
|
||
|
if err != nil {
|
||
|
log.Error("Encoding value tracker state failed", "err", err)
|
||
|
return
|
||
|
}
|
||
|
if err := vt.db.Put(vtKey, append(enc1, enc2...)); err != nil {
|
||
|
log.Error("Saving value tracker state failed", "err", err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Stop saves the value tracker's state and each loaded node's individual state and
|
||
|
// returns after shutting the internal goroutines down.
|
||
|
func (vt *ValueTracker) Stop() {
|
||
|
quit := make(chan struct{})
|
||
|
vt.quit <- quit
|
||
|
<-quit
|
||
|
vt.lock.Lock()
|
||
|
vt.periodicUpdate()
|
||
|
for id, nv := range vt.connected {
|
||
|
vt.saveNode(id, nv)
|
||
|
}
|
||
|
vt.connected = nil
|
||
|
vt.saveToDb()
|
||
|
vt.lock.Unlock()
|
||
|
}
|
||
|
|
||
|
// Register adds a server node to the value tracker
|
||
|
func (vt *ValueTracker) Register(id enode.ID) *NodeValueTracker {
|
||
|
vt.lock.Lock()
|
||
|
defer vt.lock.Unlock()
|
||
|
|
||
|
if vt.connected == nil {
|
||
|
// ValueTracker has already been stopped
|
||
|
return nil
|
||
|
}
|
||
|
nv := vt.loadOrNewNode(id)
|
||
|
nv.init(vt.clock.Now(), &vt.refBasket.reqValues)
|
||
|
vt.connected[id] = nv
|
||
|
return nv
|
||
|
}
|
||
|
|
||
|
// Unregister removes a server node from the value tracker
|
||
|
func (vt *ValueTracker) Unregister(id enode.ID) {
|
||
|
vt.lock.Lock()
|
||
|
defer vt.lock.Unlock()
|
||
|
|
||
|
if nv := vt.connected[id]; nv != nil {
|
||
|
vt.saveNode(id, nv)
|
||
|
delete(vt.connected, id)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// GetNode returns an individual server node's value tracker. If it did not exist before
|
||
|
// then a new node is created.
|
||
|
func (vt *ValueTracker) GetNode(id enode.ID) *NodeValueTracker {
|
||
|
vt.lock.Lock()
|
||
|
defer vt.lock.Unlock()
|
||
|
|
||
|
return vt.loadOrNewNode(id)
|
||
|
}
|
||
|
|
||
|
// loadOrNewNode returns an individual server node's value tracker. If it did not exist before
|
||
|
// then a new node is created.
|
||
|
func (vt *ValueTracker) loadOrNewNode(id enode.ID) *NodeValueTracker {
|
||
|
if nv, ok := vt.connected[id]; ok {
|
||
|
return nv
|
||
|
}
|
||
|
nv := &NodeValueTracker{lastTransfer: vt.clock.Now()}
|
||
|
enc, err := vt.db.Get(append(vtNodeKey, id[:]...))
|
||
|
if err != nil {
|
||
|
return nv
|
||
|
}
|
||
|
r := bytes.NewReader(enc)
|
||
|
var version uint
|
||
|
if err := rlp.Decode(r, &version); err != nil {
|
||
|
log.Error("Failed to decode node value tracker", "id", id, "err", err)
|
||
|
return nv
|
||
|
}
|
||
|
if version != nvtVersion {
|
||
|
log.Error("Unknown NodeValueTracker version", "stored", version, "current", nvtVersion)
|
||
|
return nv
|
||
|
}
|
||
|
var nve nodeValueTrackerEncV1
|
||
|
if err := rlp.Decode(r, &nve); err != nil {
|
||
|
log.Error("Failed to decode node value tracker", "id", id, "err", err)
|
||
|
return nv
|
||
|
}
|
||
|
nv.rtStats = nve.RtStats
|
||
|
nv.lastRtStats = nve.RtStats
|
||
|
if int(nve.ServerBasketMapping) == vt.currentMapping {
|
||
|
nv.basket.basket = nve.ServerBasket
|
||
|
} else {
|
||
|
if nve.ServerBasketMapping >= uint(len(vt.mappings)) {
|
||
|
log.Error("Unknown request basket mapping", "stored", nve.ServerBasketMapping, "current", vt.currentMapping)
|
||
|
return nv
|
||
|
}
|
||
|
nv.basket.basket = nve.ServerBasket.convertMapping(vt.mappings[nve.ServerBasketMapping], vt.mappings[vt.currentMapping], vt.initRefBasket)
|
||
|
}
|
||
|
return nv
|
||
|
}
|
||
|
|
||
|
// saveNode saves a server node's value tracker to the database
|
||
|
func (vt *ValueTracker) saveNode(id enode.ID, nv *NodeValueTracker) {
|
||
|
recentRtStats := nv.rtStats
|
||
|
recentRtStats.SubStats(&nv.lastRtStats)
|
||
|
vt.rtStats.AddStats(&recentRtStats)
|
||
|
nv.lastRtStats = nv.rtStats
|
||
|
|
||
|
nve := nodeValueTrackerEncV1{
|
||
|
RtStats: nv.rtStats,
|
||
|
ServerBasketMapping: uint(vt.currentMapping),
|
||
|
ServerBasket: nv.basket.basket,
|
||
|
}
|
||
|
enc1, err := rlp.EncodeToBytes(uint(nvtVersion))
|
||
|
if err != nil {
|
||
|
log.Error("Failed to encode service value information", "id", id, "err", err)
|
||
|
return
|
||
|
}
|
||
|
enc2, err := rlp.EncodeToBytes(&nve)
|
||
|
if err != nil {
|
||
|
log.Error("Failed to encode service value information", "id", id, "err", err)
|
||
|
return
|
||
|
}
|
||
|
if err := vt.db.Put(append(vtNodeKey, id[:]...), append(enc1, enc2...)); err != nil {
|
||
|
log.Error("Failed to save service value information", "id", id, "err", err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// UpdateCosts updates the node value tracker's request cost table
|
||
|
func (vt *ValueTracker) UpdateCosts(nv *NodeValueTracker, reqCosts []uint64) {
|
||
|
vt.lock.Lock()
|
||
|
defer vt.lock.Unlock()
|
||
|
|
||
|
nv.updateCosts(reqCosts, &vt.refBasket.reqValues, vt.refBasket.reqValueFactor(reqCosts))
|
||
|
}
|
||
|
|
||
|
// RtStats returns the global response time distribution statistics
|
||
|
func (vt *ValueTracker) RtStats() ResponseTimeStats {
|
||
|
vt.lock.Lock()
|
||
|
defer vt.lock.Unlock()
|
||
|
|
||
|
vt.periodicUpdate()
|
||
|
return vt.rtStats
|
||
|
}
|
||
|
|
||
|
// periodicUpdate transfers individual node data to the global statistics, normalizes
|
||
|
// the reference basket and updates request values. The global state is also saved to
|
||
|
// the database with each update.
|
||
|
func (vt *ValueTracker) periodicUpdate() {
|
||
|
now := vt.clock.Now()
|
||
|
vt.statsExpLock.Lock()
|
||
|
vt.statsExpFactor = utils.ExpFactor(vt.statsExpirer.LogOffset(now))
|
||
|
vt.statsExpLock.Unlock()
|
||
|
|
||
|
for _, nv := range vt.connected {
|
||
|
basket, rtStats := nv.transferStats(now, vt.transferRate)
|
||
|
vt.refBasket.add(basket)
|
||
|
vt.rtStats.AddStats(&rtStats)
|
||
|
}
|
||
|
vt.refBasket.normalize()
|
||
|
vt.refBasket.updateReqValues()
|
||
|
for _, nv := range vt.connected {
|
||
|
nv.updateCosts(nv.reqCosts, &vt.refBasket.reqValues, vt.refBasket.reqValueFactor(nv.reqCosts))
|
||
|
}
|
||
|
vt.saveToDb()
|
||
|
}
|
||
|
|
||
|
type ServedRequest struct {
|
||
|
ReqType, Amount uint32
|
||
|
}
|
||
|
|
||
|
// Served adds a served request to the node's statistics. An actual request may be composed
|
||
|
// of one or more request types (service vector indices).
|
||
|
func (vt *ValueTracker) Served(nv *NodeValueTracker, reqs []ServedRequest, respTime time.Duration) {
|
||
|
vt.statsExpLock.RLock()
|
||
|
expFactor := vt.statsExpFactor
|
||
|
vt.statsExpLock.RUnlock()
|
||
|
|
||
|
nv.lock.Lock()
|
||
|
defer nv.lock.Unlock()
|
||
|
|
||
|
var value float64
|
||
|
for _, r := range reqs {
|
||
|
nv.basket.add(r.ReqType, r.Amount, nv.reqCosts[r.ReqType]*uint64(r.Amount), expFactor)
|
||
|
value += (*nv.reqValues)[r.ReqType] * float64(r.Amount)
|
||
|
}
|
||
|
nv.rtStats.Add(respTime, value, vt.statsExpFactor)
|
||
|
}
|
||
|
|
||
|
type RequestStatsItem struct {
|
||
|
Name string
|
||
|
ReqAmount, ReqValue float64
|
||
|
}
|
||
|
|
||
|
// RequestStats returns the current contents of the reference request basket, with
|
||
|
// request values meaning average per request rather than total.
|
||
|
func (vt *ValueTracker) RequestStats() []RequestStatsItem {
|
||
|
vt.statsExpLock.RLock()
|
||
|
expFactor := vt.statsExpFactor
|
||
|
vt.statsExpLock.RUnlock()
|
||
|
vt.lock.Lock()
|
||
|
defer vt.lock.Unlock()
|
||
|
|
||
|
vt.periodicUpdate()
|
||
|
res := make([]RequestStatsItem, len(vt.refBasket.basket.items))
|
||
|
for i, item := range vt.refBasket.basket.items {
|
||
|
res[i].Name = vt.mappings[vt.currentMapping][i]
|
||
|
res[i].ReqAmount = expFactor.Value(float64(item.amount)/basketFactor, vt.refBasket.basket.exp)
|
||
|
res[i].ReqValue = vt.refBasket.reqValues[i]
|
||
|
}
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
// TotalServiceValue returns the total service value provided by the given node (as
|
||
|
// a function of the weights which are calculated from the request timeout value).
|
||
|
func (vt *ValueTracker) TotalServiceValue(nv *NodeValueTracker, weights ResponseTimeWeights) float64 {
|
||
|
vt.statsExpLock.RLock()
|
||
|
expFactor := vt.statsExpFactor
|
||
|
vt.statsExpLock.RUnlock()
|
||
|
|
||
|
nv.lock.Lock()
|
||
|
defer nv.lock.Unlock()
|
||
|
|
||
|
return nv.rtStats.Value(weights, expFactor)
|
||
|
}
|