Added a separate program for launching client connections. Meant mainly for server devs at this point. It is primarily useful for stress-testing so as to get profiling data using e.g. cProfiler - running many clients with a short timestep -will- slow the server to a crawl! If using sqlite3 with 50+ clients you will also see the memory consumption gradually - our sqlite3 setup runs in RAM by default and these things create a lot of new stuff every timestep - make sure to shut it down before you run out of memory! Not sure if this says much about the server's actual behaviour with many players at this point, the usage pattern is hardly "realistic". Also, it's usually better to shut down the server before the dummyrunner.

This commit is contained in:
Griatch 2012-02-05 17:58:00 +01:00
parent 44b5ae07ba
commit f306c5a6a2
2 changed files with 401 additions and 0 deletions

253
src/utils/dummyrunner.py Normal file
View file

@ -0,0 +1,253 @@
"""
Dummy client runner
This module implements a stand-alone launcher for stress-testing
an Evennia game. It will launch any number of fake clients. These
clients will log into the server and start doing random operations.
Customizing and weighing these operations differently depends on
which type of game is tested. The module contains a testing module
for plain Evennia.
Please note that you shouldn't run this on a production server!
Launch the program without any arguments or options to see a
full step-by-step setup help.
Basically (for testing default Evennia):
- Use an empty/testing database.
- set PERMISSION_PLAYERS_DEFAULT = "Builders"
- start server, eventually with profiling active
- launch this client runner
If you want to customize the runner's client actions
(because you changed the cmdset or needs to better
match your use cases or add more actions), you can
change which actions by adding a path to
DUMMYRUNNER_ACTIONS_MODULE = <path.to.your.module>
in your settings. See utils.dummyrunner_actions.py
for instructions on how to define this module.
"""
import os, sys, time, random
from optparse import OptionParser
from twisted.conch import telnet
from twisted.internet import reactor, protocol
# from twisted.application import internet, service
# from twisted.web import client
from twisted.internet.task import LoopingCall
# Tack on the root evennia directory to the python path and initialize django settings
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from django.core.management import setup_environ
from game import settings
setup_environ(settings)
from django.conf import settings
from src.utils import utils
HELPTEXT = """
DO NOT RUN THIS ON A PRODUCTION SERVER! USE A CLEAN/TESTING DATABASE!
This stand-alone program launches dummy telnet clients against a
running Evennia server. The idea is to mimic real players logging in
and repeatedly doing resource-heavy commands so as to stress test the
game. It uses the default command set to log in and issue commands, so
if that was customized, some of the functionality will not be tested
(it will not fail, the commands will just not be recognized). The
running clients will create new objects and rooms all over the place
as part of their running, so using a clean/testing database is
strongly recommended.
Setup:
1) setup a fresh/clean database (if using sqlite, just safe-copy
away your real evennia.db3 file and create a new one with
manage.py)
2) in game/settings.py, add
PERMISSION_PLAYER_DEFAULT="Builders"
3a) Start Evennia like normal.
3b) If you want profiling, start Evennia like this instead:
python runner.py -S start
this will start Evennia under cProfiler with output server.prof.
4) run this dummy runner:
python dummyclients.py <nr_of_clients> [timestep] [port]
Default is to connect one client to port 4000, using a 5 second
timestep. Increase the number of clients and shorten the
timestep (minimum is 1s) to further stress the game.
You can stop the dummy runner with Ctrl-C.
5) Log on and determine if game remains responsive despite the
heavier load. Note that if you do profiling, there is an
additional overhead from the profiler too!
6) If you use profiling, let the game run long enough to gather
data, then stop the server. You can inspect the server.prof file
from a python prompt (see Python's manual on cProfiler).
"""
#------------------------------------------------------------
# Helper functions
#------------------------------------------------------------
def idcounter():
"generates subsequent id numbers"
idcount = 0
while True:
idcount += 1
yield idcount
OID = idcounter()
CID = idcounter()
def makeiter(obj):
"makes everything iterable"
if not hasattr(obj, '__iter__'):
return [obj]
return obj
#------------------------------------------------------------
# Client classes
#------------------------------------------------------------
class DummyClient(telnet.StatefulTelnetProtocol):
"""
Handles connection to a running Evennia server,
mimicking a real player by sending commands on
a timer.
"""
def connectionMade(self):
# public properties
self.cid = CID.next()
self.istep = 0
self.exits = [] # exit names created
self.objs = [] # obj names created
self._actions = self.factory.actions
self._echo_brief = self.factory.verbose == 1
self._echo_all = self.factory.verbose == 2
print " ** client %i connected successfully." % self.cid
# start client tick
d = LoopingCall(self.step)
d.start(self.factory.timestep, now=True).addErrback(self.error)
def dataReceived(self, data):
"Echo incoming data to stdout"
if self._echo_all:
print data
def connectionLost(self, reason):
"loosing the connection"
print " ** client %i lost connection." % self.cid
def error(self, err):
"error callback"
print err
def counter(self):
"produces a unique id, also between clients"
return OID.next()
def step(self):
"""
Perform a step. This is called repeatedly by the runner
and causes the client to issue commands to the server.
This holds all "intelligence" of the dummy client.
"""
if self.istep == 0:
cfunc = self._actions[0]
else: # random selection using cumulative probabilities
rand = random.random()
cfunc = [func for cprob, func in self._actions[1] if cprob >= rand][0]
# launch the action (don't hide tracebacks)
cmd, report = cfunc(self)
# handle the result
cmd = "\n".join(makeiter(cmd))
if self._echo_brief or self._echo_all:
print "client %i %s" % (self.cid, report)
self.sendLine(cmd)
self.istep += 1
class DummyFactory(protocol.ClientFactory):
protocol = DummyClient
def __init__(self, actions, timestep, verbose):
"Setup the factory base (shared by all clients)"
self.actions = actions
self.timestep = timestep
self.verbose = verbose
#------------------------------------------------------------
# Access method:
# Starts clients and connects them to a running server.
#------------------------------------------------------------
def start_all_dummy_clients(actions, nclients=1, timestep=5, telnet_port=4000, verbose=0):
# validating and preparing the action tuple
# make sure the probabilities add up to 1
pratio = 1.0 / sum(tup[0] for tup in actions[1:])
flogin, probs, cfuncs = actions[0], [tup[0] * pratio for tup in actions[1:]], [tup[1] for tup in actions[1:]]
# create cumulative probabilies for the random actions
cprobs = [sum(v for i,v in enumerate(probs) if i<=k) for k in range(len(probs))]
# rebuild a new, optimized action structure
actions = (flogin, zip(cprobs, cfuncs))
# setting up all clients (they are automatically started)
factory = DummyFactory(actions, timestep, verbose)
for i in range(nclients):
reactor.connectTCP("localhost", telnet_port, factory)
# start reactor
reactor.run()
#------------------------------------------------------------
# Command line interface
#------------------------------------------------------------
if __name__ == '__main__':
# parsing command line with default vals
parser = OptionParser(usage="%prog [options] <nclients> [timestep, [port]]",
description="This program requires some preparations to run properly. Start it without any arguments or options for full help.")
parser.add_option('-v', '--verbose', action='store_const', const=1, dest='verbose',
default=0,help="echo brief description of what clients do every timestep.")
parser.add_option('-V', '--very-verbose', action='store_const',const=2, dest='verbose',
default=0,help="echo all client returns to stdout (hint: use only with nclients=1!)")
options, args = parser.parse_args()
nargs = len(args)
nclients = 1
timestep = 5
port = 4000
try:
if not args : raise Exception
if nargs > 0: nclients = max(1, int(args[0]))
if nargs > 1: timestep = max(1, int(args[1]))
if nargs > 2: port = int(args[2])
except Exception:
print HELPTEXT
sys.exit()
# import the ACTION tuple from a given module
try:
action_modpath = settings.DUMMYRUNNER_ACTIONS_MODULE
except AttributeError:
# use default
action_modpath = "src.utils.dummyrunner_actions"
actions = utils.mod_import(action_modpath, "ACTIONS")
print "Connecting %i dummy client(s) to port %i using a %i second timestep ... " % (nclients, port, timestep)
start_all_dummy_clients(actions, nclients, timestep, port,
verbose=options.verbose)
print "... dummy client runner finished."

View file

@ -0,0 +1,148 @@
"""
These are actions for the dummy client runner, using
the default command set and intended for unmodified Evennia.
Each client action is defined as a function. The clients
will perform these actions randomly (except the login action).
Each action-definition function should take one argument- "client",
which is a reference to the client currently performing the action
Use the client object for saving data between actions.
The client object has the following relevant properties and methods:
cid - unique client id
istep - the current step
exits - an empty list. Can be used to store exit names
objs - an empty list. Can be used to store object names
counter() - get an integer value. This counts up for every call and
is always unique between clients.
The action-definition function should return the command that the
client should send to the server (as if it was input in a mud client).
It should also return a string detailing the action taken. This string is
used by the "brief verbose" mode of the runner and is prepended by
"Client N " to produce output like "Client 3 is creating objects ..."
This module *must* also define a variable named ACTIONS. This is a tuple
where the first element is the function object for the action function
to call when the client logs onto the server. The following elements
are 2-tuples (probability, action_func), where probability defines how
common it is for that particular action to happen. The runner will
randomly pick between those functions based on the probability.
ACTIONS = (login_func, (0.3, func1), (0.1, func2) ... )
To change the runner to use your custom ACTION and/or action
definitions, edit settings.py and add
DUMMYRUNNER_ACTIONS_MODULE = "path.to.your.module"
"""
# it's very useful to have a unique id for this run to avoid any risk
# of clashes
import time
RUNID = time.time()
# some convenient templates
START_ROOM = "testing_room_start-%s-%s" % (RUNID, "%i")
ROOM_TEMPLATE = "testing_room_%s-%s" % (RUNID, "%i")
EXIT_TEMPLATE = "exit_%s-%s" % (RUNID, "%i")
OBJ_TEMPLATE = "testing_obj_%s-%s" % (RUNID, "%i")
TOBJ_TEMPLATE = "testing_button_%s-%s" % (RUNID, "%i")
TOBJ_TYPECLASS = "examples.red_button.RedButton"
# action function definitions
def c_login(client):
"logins to the game"
cname = "Dummy-%s-%i" % (RUNID, client.cid)
cemail = "%s@dummy.com" % (cname.lower())
cpwd = "%s-%s" % (RUNID, client.cid)
cmd = ('create "%s" %s %s' % (cname, cemail, cpwd),
'connect %s %s' % (cemail, cpwd),
'@dig %s' % START_ROOM % client.cid,
'@teleport %s' % START_ROOM % client.cid)
return cmd, "logs into game as %s ..." % cname
def c_looks(client):
"looks at various objects"
cmd = ["look %s" % obj for obj in client.objs]
if not cmd:
cmd = ["look %s" % exi for exi in client.exits]
if not cmd:
cmd = "look"
return cmd, "looks ..."
def c_examines(client):
"examines various objects"
cmd = ["examine %s" % obj for obj in client.objs]
if not cmd:
cmd = ["examine %s" % exi for exi in client.exits]
if not cmd:
cmd = "examine me"
return cmd, "examines objs ..."
def c_help(client):
"reads help files"
cmd = ('help',
'help @teleport',
'help look',
'help @tunnel',
'help @dig')
return cmd, "reads help ..."
def c_digs(client):
"digs a new room, storing exit names on client"
roomname = ROOM_TEMPLATE % client.counter()
exitname1 = EXIT_TEMPLATE % client.counter()
exitname2 = EXIT_TEMPLATE % client.counter()
client.exits.extend([exitname1, exitname2])
cmd = '@dig %s = %s, %s' % (roomname, exitname1, exitname2)
return cmd, "digs ..."
def c_creates_obj(client):
"creates normal objects, storing their name on client"
objname = OBJ_TEMPLATE % client.counter()
client.objs.append(objname)
cmd = ('@create %s' % objname,
'@desc %s = "this is a test object' % objname,
'@set %s/testattr = this is a test attribute value.' % objname,
'@set %s/testattr2 = this is a second test attribute.' % objname)
return cmd, "creates obj ..."
def c_creates_button(client):
"creates example button, storing name on client"
objname = TOBJ_TEMPLATE % client.counter()
client.objs.append(objname)
cmd = ('@create %s:%s' % (objname, TOBJ_TYPECLASS),
'@desc %s = test red button!' % objname)
return cmd, "creates button ..."
def c_moves(client):
"moves to a previously created room, using the stored exits"
cmd = client.exits # try all exits - finally one will work
if not cmd: cmd = "look"
return cmd, "moves ..."
# Action tuple (required)
#
# This is a tuple of client action functions. The first element is the
# function the client should use to log into the game and move to
# STARTROOM . The following elements are 2-tuples of (probability,
# action_function). The probablities should normally sum up to 1,
# otherwise the system will normalize them.
#
ACTIONS = ( c_login,
(0.2, c_looks),
(0.1, c_examines),
(0.2, c_help),
(0.1, c_digs),
(0.1, c_creates_obj),
#(0.1, c_creates_button),
(0.2, c_moves))