From e6ab89f3136b648d97324240e8a0087080f02117 Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Tue, 20 Mar 2007 17:23:18 +0000 Subject: [PATCH] Extracted the Bang stats system into reusable land. Too late for Yohoho, but Whirled will benefit. git-svn-id: svn+ssh://src.earth.threerings.net/vilya/trunk@251 c613c5cb-e716-0410-b11b-feb51c14d237 --- build.xml | 6 + src/java/com/threerings/stats/Log.java | 15 + .../stats/data/ByteByteStringMapStat.java | 44 ++ .../stats/data/ByteStringSetStat.java | 35 ++ .../threerings/stats/data/IntArrayStat.java | 75 +++ .../com/threerings/stats/data/IntStat.java | 71 +++ .../stats/data/IntStringSetStat.java | 35 ++ .../com/threerings/stats/data/MaxIntStat.java | 61 +++ .../stats/data/ShortStringSetStat.java | 35 ++ src/java/com/threerings/stats/data/Stat.java | 200 ++++++++ .../com/threerings/stats/data/StatSet.java | 222 +++++++++ .../threerings/stats/data/StringMapStat.java | 122 +++++ .../threerings/stats/data/StringSetStat.java | 68 +++ .../stats/server/persist/StatRepository.java | 460 ++++++++++++++++++ 14 files changed, 1449 insertions(+) create mode 100644 src/java/com/threerings/stats/Log.java create mode 100644 src/java/com/threerings/stats/data/ByteByteStringMapStat.java create mode 100644 src/java/com/threerings/stats/data/ByteStringSetStat.java create mode 100644 src/java/com/threerings/stats/data/IntArrayStat.java create mode 100644 src/java/com/threerings/stats/data/IntStat.java create mode 100644 src/java/com/threerings/stats/data/IntStringSetStat.java create mode 100644 src/java/com/threerings/stats/data/MaxIntStat.java create mode 100644 src/java/com/threerings/stats/data/ShortStringSetStat.java create mode 100644 src/java/com/threerings/stats/data/Stat.java create mode 100644 src/java/com/threerings/stats/data/StatSet.java create mode 100644 src/java/com/threerings/stats/data/StringMapStat.java create mode 100644 src/java/com/threerings/stats/data/StringSetStat.java create mode 100644 src/java/com/threerings/stats/server/persist/StatRepository.java diff --git a/build.xml b/build.xml index 38c054d0..ee275bbe 100644 --- a/build.xml +++ b/build.xml @@ -204,6 +204,10 @@ + + + + @@ -238,6 +242,7 @@ + @@ -245,6 +250,7 @@ + diff --git a/src/java/com/threerings/stats/Log.java b/src/java/com/threerings/stats/Log.java new file mode 100644 index 00000000..ffb87e23 --- /dev/null +++ b/src/java/com/threerings/stats/Log.java @@ -0,0 +1,15 @@ +// +// $Id$ + +package com.threerings.stats; + +import java.util.logging.Logger; + +/** + * A placeholder class that contains a reference to the log object used by this project. + */ +public class Log +{ + /** We dispatch our log messages through this logger. */ + public static Logger log = Logger.getLogger("com.threerings.stats"); +} diff --git a/src/java/com/threerings/stats/data/ByteByteStringMapStat.java b/src/java/com/threerings/stats/data/ByteByteStringMapStat.java new file mode 100644 index 00000000..a8e5e458 --- /dev/null +++ b/src/java/com/threerings/stats/data/ByteByteStringMapStat.java @@ -0,0 +1,44 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +/** + * Maps up to 127 string values to integers in the range 0 - 127. + */ +public class ByteByteStringMapStat extends StringMapStat +{ + @Override // documentation inherited + public void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException + { + out.writeByte(_keys.length); + for (int ii = 0; ii < _keys.length; ii++) { + out.writeByte((byte)aux.getStringCode(_type, _keys[ii])); + out.writeByte((byte)_values[ii]); + } + } + + @Override // documentation inherited + public void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException + { + _keys = new String[in.readByte()]; + _values = new int[_keys.length]; + for (int ii = 0; ii < _keys.length; ii++) { + _keys[ii] = aux.getCodeString(_type, in.readByte()); + _values[ii] = in.readByte(); + } + } + + @Override // from StringMapStat + protected int getMaxValue () + { + return Byte.MAX_VALUE; + } +} diff --git a/src/java/com/threerings/stats/data/ByteStringSetStat.java b/src/java/com/threerings/stats/data/ByteStringSetStat.java new file mode 100644 index 00000000..d919d81b --- /dev/null +++ b/src/java/com/threerings/stats/data/ByteStringSetStat.java @@ -0,0 +1,35 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +/** + * A string set that maps its values to bytes. + */ +public class ByteStringSetStat extends StringSetStat +{ + @Override // documentation inherited + public void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException + { + out.writeByte(_values.length); + for (int ii = 0; ii < _values.length; ii++) { + out.writeByte((byte)aux.getStringCode(_type, _values[ii])); + } + } + + @Override // documentation inherited + public void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException + { + _values = new String[in.readByte()]; + for (int ii = 0; ii < _values.length; ii++) { + _values[ii] = aux.getCodeString(_type, in.readByte()); + } + } +} diff --git a/src/java/com/threerings/stats/data/IntArrayStat.java b/src/java/com/threerings/stats/data/IntArrayStat.java new file mode 100644 index 00000000..3560d2b0 --- /dev/null +++ b/src/java/com/threerings/stats/data/IntArrayStat.java @@ -0,0 +1,75 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; + +import java.util.Arrays; + +import com.samskivert.util.ArrayUtil; +import com.samskivert.util.StringUtil; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +/** + * Used to track an integer array statistic. + */ +public class IntArrayStat extends Stat +{ + /** + * Returns the value of this statistic. + */ + public int[] getValue () + { + return _value; + } + + /** + * Sets this statistic's value to the specified value. + * + * @return true if the stat was modified, false if not. + */ + public boolean setValue (int[] value) + { + if (!Arrays.equals(_value, value)) { + _value = value; + _modified = true; + return true; + } + return false; + } + + /** + * Appends a value to this statistic. + */ + public void appendValue (int value) + { + _value = ArrayUtil.append(_value, value); + _modified = true; + } + + @Override // documentation inherited + public String valueToString () + { + return StringUtil.toString(_value); + } + + @Override // documentation inherited + public void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException + { + out.writeObject(_value); + } + + @Override // documentation inherited + public void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException + { + _value = (int[])in.readObject(); + } + + /** Contains the integer list value of this statistic. */ + protected int[] _value = new int[0]; +} diff --git a/src/java/com/threerings/stats/data/IntStat.java b/src/java/com/threerings/stats/data/IntStat.java new file mode 100644 index 00000000..dd06c90b --- /dev/null +++ b/src/java/com/threerings/stats/data/IntStat.java @@ -0,0 +1,71 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +/** + * Used to track a single integer statistic. + */ +public class IntStat extends Stat +{ + /** + * Returns the value of this integer statistic. + */ + public int getValue () + { + return _value; + } + + /** + * Sets this statistic's value to the specified value. + * + * @return true if the stat was modified, false if not. + */ + public boolean setValue (int value) + { + if (value != _value) { + _value = value; + _modified = true; + return true; + } + return false; + } + + /** + * Increments this statistic by the specified delta value. + * + * @return true if the stat was modified, false if not. + */ + public boolean increment (int delta) + { + return setValue(_value + delta); + } + + @Override // documentation inherited + public String valueToString () + { + return String.valueOf(_value); + } + + @Override // documentation inherited + public void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException + { + out.writeInt(_value); + } + + @Override // documentation inherited + public void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException + { + _value = in.readInt(); + } + + /** Contains the integer value of this statistic. */ + protected int _value; +} diff --git a/src/java/com/threerings/stats/data/IntStringSetStat.java b/src/java/com/threerings/stats/data/IntStringSetStat.java new file mode 100644 index 00000000..3da5e2b8 --- /dev/null +++ b/src/java/com/threerings/stats/data/IntStringSetStat.java @@ -0,0 +1,35 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +/** + * A string set that maps its values to integers. + */ +public class IntStringSetStat extends StringSetStat +{ + @Override // documentation inherited + public void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException + { + out.writeInt(_values.length); + for (int ii = 0; ii < _values.length; ii++) { + out.writeInt(aux.getStringCode(_type, _values[ii])); + } + } + + @Override // documentation inherited + public void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException + { + _values = new String[in.readInt()]; + for (int ii = 0; ii < _values.length; ii++) { + _values[ii] = aux.getCodeString(_type, in.readInt()); + } + } +} diff --git a/src/java/com/threerings/stats/data/MaxIntStat.java b/src/java/com/threerings/stats/data/MaxIntStat.java new file mode 100644 index 00000000..db6c0285 --- /dev/null +++ b/src/java/com/threerings/stats/data/MaxIntStat.java @@ -0,0 +1,61 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; +import java.io.EOFException; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +/** + * Extends the {@link IntStat} by maintaining a maximum value which is updated every time a value + * is accumulated to the stat. Thus we track an accumulating value as well as the largest amount by + * which it has ever accumulated. + */ +public class MaxIntStat extends IntStat +{ + /** + * Returns the maximum value every accumulated to this integer statistic. + */ + public int getMaxValue () + { + return _maxValue; + } + + @Override // from IntStat + public boolean increment (int delta) + { + if (super.increment(delta)) { + _maxValue = Math.max(delta, _maxValue); + return true; + } + return false; + } + + @Override // from IntStat + public String valueToString () + { + return super.valueToString() + " " + String.valueOf(_maxValue); + } + + @Override // from IntStat + public void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException + { + super.persistTo(out, aux); + out.writeInt(_maxValue); + } + + @Override // from IntStat + public void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException + { + super.unpersistFrom(in, aux); + _maxValue = in.readInt(); + } + + /** The largest value ever accumulated to this stat. */ + protected int _maxValue; +} diff --git a/src/java/com/threerings/stats/data/ShortStringSetStat.java b/src/java/com/threerings/stats/data/ShortStringSetStat.java new file mode 100644 index 00000000..adb8cbf2 --- /dev/null +++ b/src/java/com/threerings/stats/data/ShortStringSetStat.java @@ -0,0 +1,35 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +/** + * A string set that maps its values to shorts. + */ +public class ShortStringSetStat extends StringSetStat +{ + @Override // documentation inherited + public void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException + { + out.writeShort(_values.length); + for (int ii = 0; ii < _values.length; ii++) { + out.writeShort((short)aux.getStringCode(_type, _values[ii])); + } + } + + @Override // documentation inherited + public void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException + { + _values = new String[in.readShort()]; + for (int ii = 0; ii < _values.length; ii++) { + _values[ii] = aux.getCodeString(_type, in.readShort()); + } + } +} diff --git a/src/java/com/threerings/stats/data/Stat.java b/src/java/com/threerings/stats/data/Stat.java new file mode 100644 index 00000000..6d40fc1d --- /dev/null +++ b/src/java/com/threerings/stats/data/Stat.java @@ -0,0 +1,200 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.io.IOException; +import java.util.zip.CRC32; + +import com.samskivert.util.HashIntMap; +import com.samskivert.util.StringUtil; +import com.threerings.util.MessageBundle; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +import com.threerings.presents.dobj.DSet; + +import static com.threerings.stats.Log.log; + +/** + * A base class for persistent statistics tracked on a per-player basis (some for a single game, + * others for all time). + */ +public abstract class Stat + implements DSet.Entry, Cloneable +{ + /** + * Defines the various per-player tracked statistics. + */ + public interface Type + { + /** Returns a new blank stat instance of the specified type. */ + public Stat newStat (); + + /** Returns the enum name of stat. */ + public String name (); + + /** Returns the unique code for this stat which is a function of its name. */ + public int code (); + + /** Returns true if this stat is persisted between sessions. */ + public boolean isPersistent (); + }; + + /** Provides auxilliary information to statistics during the persisting process. */ + public static interface AuxDataSource + { + /** Maps the specified string to a unique integer value. */ + public int getStringCode (Type type, String value); + + /** Maps the specified unique code back to its string value. */ + public String getCodeString (Type type, int code); + } + + /** + * Maps a {@link Type}'s code code back to a {@link Type} instance. + */ + public static Type getType (int code) + { + return _codeToType.get(code); + } + + /** + * Used by the {@link Type} implementation to map itself to an integer code. + * + * @return the code to which the supplied type was mapped. + */ + public static int initType (Type type, Stat prototype) + { + // compute a code for this stat using the CRC32 hash of its name + _crc.reset(); + _crc.update(type.name().getBytes()); + int code = (int)_crc.getValue(); + + // make sure the code does not collide + if (_codeToType.containsKey(code)) { + log.warning("Stat type collision! " + type.name() + " and " + + _codeToType.get(code).name() + " both map to '" + code + "'."); + return -1; + } + + // initialize the prototype + prototype._type = type; + + // map the stat by its code + _codeToType.put(code, type); + return code; + } + + /** + * Returns the type of this statistic. + */ + public Type getType () + { + return _type; + } + + /** + * Returns the integer code to which this statistic's name maps. + */ + public int getCode () + { + return _type.code(); + } + + /** + * Returns true if the supplied statistic has been modified since it + * was loaded from the repository. + */ + public boolean isModified () + { + return _modified; + } + + /** + * Forces this stat to consider itself modified. Generally this is not + * called but rather the derived class will update its modified state when + * it is actually modified. + */ + public void setModified (boolean modified) + { + _modified = modified; + } + + /** Writes our custom streamable fields. */ + public void writeObject (ObjectOutputStream out) + throws IOException + { + out.writeInt(_type.code()); + out.defaultWriteObject(); + } + + /** Reads our custom streamable fields. */ + public void readObject (ObjectInputStream in) + throws IOException, ClassNotFoundException + { + _type = getType(in.readInt()); + in.defaultReadObject(); + } + + /** + * Serializes this instance for storage in the item database. Derived classes must override + * this method to implement persistence. + */ + public abstract void persistTo (ObjectOutputStream out, AuxDataSource aux) + throws IOException; + + /** + * Unserializes this item from data obtained from the item database. Derived classes must + * override this method to implement persistence. + */ + public abstract void unpersistFrom (ObjectInputStream in, AuxDataSource aux) + throws IOException, ClassNotFoundException; + + /** + * Generates a string representation of this instance. + */ + public String toString () + { + StringBuffer buf = new StringBuffer(StringUtil.toUSLowerCase(_type.name())); + buf.append("="); + buf.append(valueToString()); + return buf.toString(); + } + + /** + * Derived statistics must override this method and render their value to a string. Used by + * {@link #toString} and to display the value in game. + */ + public abstract String valueToString (); + + // documentation inherited from DSet.Entry + public String getKey () + { + return _type.name(); + } + + // documentation inherited from Cloneable + public Object clone () + { + try { + return super.clone(); + } catch (Exception e) { + throw new RuntimeException("Clone failed", e); + } + } + + /** The type of the statistic in question. */ + protected transient Type _type; + + /** Indicates whether or not this statistic has been modified since it + * was loaded from the database. */ + protected transient boolean _modified; + + /** Used for computing our stat codes. */ + protected static CRC32 _crc = new CRC32(); + + /** The table mapping stat codes to enumerated types. */ + protected static HashIntMap _codeToType = new HashIntMap(); +} diff --git a/src/java/com/threerings/stats/data/StatSet.java b/src/java/com/threerings/stats/data/StatSet.java new file mode 100644 index 00000000..d0c5f99e --- /dev/null +++ b/src/java/com/threerings/stats/data/StatSet.java @@ -0,0 +1,222 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import java.util.Iterator; + +import com.threerings.presents.dobj.DSet; + +/** + * A distributed set containing {@link Stat} objects. + */ +public final class StatSet extends DSet +{ + /** An interface to be implemented by an entity that wishes to be notified when the contents of + * a stat set change. */ + public interface Container + { + public void addToStats (Stat stat); + public void updateStats (Stat stat); + } + + /** Creates a stat set with the specified contents. */ + public StatSet (Iterator contents) + { + super(contents); + } + + /** Creates a blank stat set. */ + public StatSet () + { + } + + /** + * Wires this stat set up to a containing user object. All subsequent modifications will be + * published to the container. + */ + public void setContainer (Container container) + { + _container = container; + } + + /** + * Sets an integer statistic in this set. + * + * @exception ClassCastException thrown if the registered type of the specified stat is not an + * {@link IntStat}. + */ + public void setStat (Stat.Type type, int value) + { + IntStat stat = (IntStat)get(type.name()); + if (stat == null) { + stat = (IntStat)type.newStat(); + stat.setValue(value); + addStat(stat); + } else if (stat.setValue(value)) { + updateStat(stat); + } + } + + /** + * Sets an integer stat to the specified value, if it exceeds our existing recorded value. + * + * @exception ClassCastException thrown if the registered type of the specified stat is not an + * {@link IntStat}. + */ + public void maxStat (Stat.Type type, int value) + { + int ovalue = getIntStat(type); + if (value > ovalue) { + setStat(type, value); + } + } + + /** + * Increments an integer statistic in this set. + * + * @exception ClassCastException thrown if the registered type of the specified stat is not an + * {@link IntStat}. + */ + public void incrementStat (Stat.Type type, int delta) + { + IntStat stat = (IntStat)get(type.name()); + if (stat == null) { + stat = (IntStat)type.newStat(); + stat.increment(delta); + addStat(stat); + } else if (stat.increment(delta)) { + updateStat(stat); + } + } + + /** + * Appends an integer value to an {@link IntArrayStat}. + * + * @exception ClassCastException thrown if the registered type of the specified stat is not an + * {@link IntArrayStat}. + */ + public void appendStat (Stat.Type type, int value) + { + IntArrayStat stat = (IntArrayStat)get(type.name()); + if (stat == null) { + stat = (IntArrayStat)type.newStat(); + stat.appendValue(value); + addStat(stat); + } else { + stat.appendValue(value); + updateStat(stat); + } + } + + /** + * Adds a string value to a {@link StringSetStat}. + * + * @exception ClassCastException thrown if the registered type of the specified stat is not an + * {@link StringSetStat}. + */ + public void addToSetStat (Stat.Type type, String value) + { + StringSetStat stat = (StringSetStat)get(type.name()); + if (stat == null) { + stat = (StringSetStat)type.newStat(); + stat.add(value); + addStat(stat); + } else if (stat.add(value)) { + updateStat(stat); + } + } + + /** + * Increments a string value in a {@link StringMapStat}. + * + * @exception ClassCastException thrown if the registered type of the specified stat is not an + * {@link StringMapStat}. + */ + public void incrementMapStat (Stat.Type type, String value, int amount) + { + StringMapStat stat = (StringMapStat)get(type.name()); + if (stat == null) { + stat = (StringMapStat)type.newStat(); + stat.increment(value, amount); + addStat(stat); + } else if (stat.increment(value, amount)) { + updateStat(stat); + } + } + + /** + * Returns the current value of the specified integer statistic. + */ + public int getIntStat (Stat.Type type) + { + IntStat stat = (IntStat)get(type.name()); + return (stat == null) ? 0 : stat.getValue(); + } + + /** + * Returns the maximum value by which the specified integer statistic has ever been + * incremented. + */ + public int getMaxIntStat (Stat.Type type) + { + MaxIntStat stat = (MaxIntStat)get(type.name()); + return (stat == null) ? 0 : stat.getMaxValue(); + } + + /** + * Returns the current value of the specified integer array statistic. + */ + public int[] getIntArrayStat (Stat.Type type) + { + IntArrayStat stat = (IntArrayStat)get(type.name()); + return (stat == null) ? new int[0] : stat.getValue(); + } + + /** + * Returns true if the specified {@link StringSetStat} contains the specified value, false + * otherwise. + */ + public boolean containsValue (Stat.Type type, String value) + { + StringSetStat stat = (StringSetStat)get(type.name()); + return (stat == null) ? false : stat.contains(value); + } + + /** + * Returns the value to which the specified string is mapped in a {@link StringMapStat} or zero + * if the value has not been mapped. + */ + public int getMapValue (Stat.Type type, String value) + { + StringMapStat stat = (StringMapStat)get(type.name()); + return (stat == null) ? 0 : stat.get(value); + } + + /** + * Don't call this method, it's only needed by some tricky business we do when preventing + * distributed object event generation for all but the bounty criteria related stats. + */ + public void addQuietly (Stat stat) + { + add(stat); + } + + protected void addStat (Stat stat) + { + if (_container != null) { + _container.addToStats(stat); + } else { + add(stat); + } + } + + protected void updateStat (Stat stat) + { + if (_container != null) { + _container.updateStats(stat); + } + } + + protected transient Container _container; +} diff --git a/src/java/com/threerings/stats/data/StringMapStat.java b/src/java/com/threerings/stats/data/StringMapStat.java new file mode 100644 index 00000000..b1930af2 --- /dev/null +++ b/src/java/com/threerings/stats/data/StringMapStat.java @@ -0,0 +1,122 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import com.samskivert.util.ArrayUtil; +import com.samskivert.util.StringUtil; + +/** + * Used to track a statistic comprised of a set of strings that map to numeric counts. + */ +public abstract class StringMapStat extends Stat +{ + /** + * Returns true if the specified key is contained in this map. + */ + public boolean containsKey (String key) + { + return (key == null) ? false : + ArrayUtil.binarySearch(_keys, 0, _keys.length, key) >= 0; + } + + /** + * Returns the value to which the specified key maps or zero if it is not contained in this + * map. + */ + public int get (String key) + { + if (key == null) { + return 0; + } + int iidx = ArrayUtil.binarySearch(_keys, 0, _keys.length, key); + return (iidx < 0) ? 0 : _values[iidx]; + } + + /** + * Adds the specified key value pair to this set, overwriting any existing mapping. + * + * @return true if the stat's value was changed, false otherwise. + */ + public boolean put (String key, int value) + { + if (key == null) { + return false; + } + int iidx = getOrCreateEntry(key); + int ovalue = _values[iidx]; + _values[iidx] = Math.min(value, getMaxValue()); + if (_values[iidx] != ovalue) { + _modified = true; + return true; + } + return false; + } + + /** + * Adds the specified key value pair to this set, overwriting any existing mapping. + * + * @return true if the stat's value was changed, false otherwise. + */ + public boolean increment (String key, int amount) + { + if (key == null) { + return false; + } + int iidx = getOrCreateEntry(key); + int ovalue = _values[iidx]; + _values[iidx] = Math.min(_values[iidx] + amount, getMaxValue()); + if (_values[iidx] != ovalue) { + _modified = true; + return true; + } + return false; + } + + @Override // documentation inherited + public String valueToString () + { + StringBuffer buf = new StringBuffer("["); + for (int ii = 0; ii < _keys.length; ii++) { + if (ii > 0) { + buf.append(", "); + } + buf.append(_keys[ii]).append("->").append(_values[ii]); + } + return buf.append("]").toString(); + } + + /** + * Returns the index of the specified key, creating an entry if necessary. + */ + protected int getOrCreateEntry (String key) + { + int iidx = ArrayUtil.binarySearch(_keys, 0, _keys.length, key); + if (iidx < 0) { + iidx = -iidx - 1; + String[] keys = new String[_keys.length+1]; + System.arraycopy(_keys, 0, keys, 0, iidx); + System.arraycopy(_keys, iidx, keys, iidx+1, (_keys.length-iidx)); + keys[iidx] = key; + _keys = keys; + int[] values = new int[_values.length+1]; + System.arraycopy(_values, 0, values, 0, iidx); + System.arraycopy( + _values, iidx, values, iidx+1, (_values.length-iidx)); + _values = values; + } + return iidx; + } + + /** + * Returns the maximum value to which we'll allow our strings to map (to avoid overflowing our + * underlying data types and to avoid accumulating forever when we don't really care). + */ + protected abstract int getMaxValue (); + + /** Contains the string keys in this set. */ + protected String[] _keys = {}; + + /** Contains the values in this set. */ + protected int[] _values = {}; +} diff --git a/src/java/com/threerings/stats/data/StringSetStat.java b/src/java/com/threerings/stats/data/StringSetStat.java new file mode 100644 index 00000000..8fb259f4 --- /dev/null +++ b/src/java/com/threerings/stats/data/StringSetStat.java @@ -0,0 +1,68 @@ +// +// $Id$ + +package com.threerings.stats.data; + +import com.samskivert.util.ArrayUtil; +import com.samskivert.util.StringUtil; + +/** + * Used to track a statistic comprised of a set of strings. + */ +public abstract class StringSetStat extends Stat +{ + /** + * Returns true if the specified string is contained in this set. + */ + public boolean contains (String key) + { + if (key == null) { + throw new IllegalArgumentException("StringSetStat cannot contain null"); + } + return ArrayUtil.binarySearch(_values, 0, _values.length, key) >= 0; + } + + /** + * Adds the specified string to this set. + * + * @return true if the string was newly added, false if it was already + * contained in the set. + */ + public boolean add (String key) + { + if (key == null) { + throw new IllegalArgumentException("Cannot add null to StringSetStat"); + } + + int iidx = ArrayUtil.binarySearch(_values, 0, _values.length, key); + if (iidx >= 0) { + return false; + } + + iidx = -iidx - 1; + String[] values = new String[_values.length+1]; + System.arraycopy(_values, 0, values, 0, iidx); + System.arraycopy(_values, iidx, values, iidx+1, (_values.length-iidx)); + values[iidx] = key; + _values = values; + _modified = true; + return true; + } + + /** + * Returns the values in this set. Do not modify the returned array. + */ + public String[] values () + { + return _values; + } + + @Override // documentation inherited + public String valueToString () + { + return StringUtil.toString(_values); + } + + /** Contains the strings in this set. */ + protected String[] _values = new String[0]; +} diff --git a/src/java/com/threerings/stats/server/persist/StatRepository.java b/src/java/com/threerings/stats/server/persist/StatRepository.java new file mode 100644 index 00000000..c6edeca3 --- /dev/null +++ b/src/java/com/threerings/stats/server/persist/StatRepository.java @@ -0,0 +1,460 @@ +// +// $Id$ + +package com.threerings.stats.server.persist; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.logging.Level; + +import com.samskivert.io.ByteArrayOutInputStream; +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.SimpleRepository; +import com.samskivert.util.HashIntMap; +import com.samskivert.util.StringUtil; + +import com.threerings.io.ObjectInputStream; +import com.threerings.io.ObjectOutputStream; + +import com.threerings.stats.data.Stat; + +import static com.threerings.stats.Log.log; + +/** + * Responsible for the persistent storage of per-player statistics. + */ +public class StatRepository extends SimpleRepository + implements Stat.AuxDataSource +{ + /** Used by {@link #processStats}. */ + public interface Processor + { + /** + * Called on every stat matching the criterion supplied to {@link #processStats}. + * Note: do not retain a reference to the supplied {@link Stat} instance as its + * contents will be overwritten prior to each call to process. + */ + public void process (int playerId, String accountName, String handle, + Date created, int sessionMinutes, Stat stat); + } + + /** + * The database identifier used when establishing a database connection. This value being + * statdb. + */ + public static final String STAT_DB_IDENT = "statdb"; + + /** + * Constructs a new statistics repository with the specified connection provider. + * + * @param conprov the connection provider via which we will obtain our + * database connection. + */ + public StatRepository (ConnectionProvider conprov) + throws PersistenceException + { + super(conprov, STAT_DB_IDENT); + + // load up our string set mappings + loadStringCodes(null); + } + + /** + * Loads the stats associated with the specified player. + */ + public ArrayList loadStats (final int playerId) + throws PersistenceException + { + final ArrayList stats = new ArrayList(); + final String query = "select STAT_CODE, STAT_DATA from STATS where PLAYER_ID = " + playerId; + execute(new Operation() { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Statement stmt = conn.createStatement(); + try { + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + Stat stat = decodeStat( + rs.getInt(1), (byte[])rs.getObject(2)); + if (stat != null) { + stats.add(stat); + } + } + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + return stats; + } + + /** + * Writes out any of the stats in the supplied array that have been modified since they were + * first loaded. Exceptions that occur while writing the stats will be caught and logged. + */ + public void writeModified (int playerId, Stat[] stats) + { + for (int ii = 0; ii < stats.length; ii++) { + try { + if (stats[ii].getType().isPersistent() && + stats[ii].isModified()) { + updateStat(playerId, stats[ii]); + } + } catch (Exception e) { + log.log(Level.WARNING, "Error flushing modified stat [stat=" + stats[ii] + "].", e); + } + } + } + + // documentation inherited from interface Stat.AuxDataSource + public int getStringCode (Stat.Type type, String value) + { + HashMap map = _stringToCode.get(type); + if (map == null) { + _stringToCode.put(type, map = new HashMap()); + } + Integer code = map.get(value); + if (code == null) { + try { + code = assignStringCode(type, value); + } catch (PersistenceException pe) { + log.log(Level.WARNING, "Failed to assign code [type=" + type + + ", value=" + value + "].", pe); + // at this point the database is probably totally hosed, so we can just punt here, + // and assume that this value will never be persisted + code = -1; + } + mapStringCode(type, value, code); + } + return code; + } + + // documentation inherited from interface Stat.AuxDataSource + public String getCodeString (Stat.Type type, int code) + { + HashIntMap map = _codeToString.get(type); + String value = (map == null) ? null : map.get(code); + if (value == null) { + // our value may have been mapped on a different server, so refresh + // this mapping table from the database; then try again + try { + loadStringCodes(type); + } catch (PersistenceException pe) { + log.log(Level.WARNING, "Failed to reload string codes " + + "[type=" + type + ", code=" + code + "].", pe); + } + map = _codeToString.get(type); + value = (map == null) ? null : map.get(code); + if (value == null) { + log.warning("Missing reverse maping [type=" + type + + ", code=" + code + "]."); + } + } + return value; + } + + /** + * This is only used for testing. Do not call this method. + */ + public void clearMapping (Stat.Type type, String value) + { + int ocode = _stringToCode.get(type).remove(value); + _codeToString.get(type).remove(ocode); + } + + /** + * Invokes the supplied processor on every stat in the database of the specified type. + * + *

Note: the stats database will inevitable be extremely large (one row for every + * paying player and one for non-payers less than six months old; millions of rows if the game + * is at all successful). Don't call this method willy nilly and the summarized results should + * be cached for at least 12 hours. (Stats don't change that frequently in the aggregate.) + */ + public void processStats (final Processor processor, Stat.Type type) + throws PersistenceException + { + final Stat stat = type.newStat(); + final String query = "select STATS.PLAYER_ID, ACCOUNT_NAME, HANDLE, CREATED, " + + "SESSION_MINUTES, STAT_DATA from STATS, PLAYERS " + + "where PLAYERS.PLAYER_ID = STATS.PLAYER_ID and STAT_CODE = " + type.code(); + execute(new Operation() { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Statement stmt = conn.createStatement(); + try { + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + if (decodeStat(stat, (byte[])rs.getObject(6)) != null) { + processor.process(rs.getInt(1), rs.getString(2), rs.getString(3), + rs.getDate(4), rs.getInt(5), stat); + } + } + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + /** + * Instantiates the appropriate stat class and decodes the stat from the data. + */ + protected Stat decodeStat (int statCode, byte[] data) + { + Stat.Type type = Stat.getType(statCode); + if (type == null) { + log.warning("Unable to decode stat, unknown type [code=" + statCode + "]."); + return null; + } + return decodeStat(type.newStat(), data); + } + + /** + * Instantiates the appropriate stat class and decodes the stat from the data. + */ + protected Stat decodeStat (Stat stat, byte[] data) + { + String errmsg = null; + Exception error = null; + + try { + // decode its contents from the serialized data + ByteArrayInputStream bin = new ByteArrayInputStream(data); + stat.unpersistFrom(new ObjectInputStream(bin), this); + return stat; + + } catch (ClassNotFoundException cnfe) { + error = cnfe; + errmsg = "Unable to instantiate stat"; + + } catch (IOException ioe) { + error = ioe; + errmsg = "Unable to decode stat"; + } + + log.log(Level.WARNING, errmsg + " [type=" + stat.getType() + "]", error); + return null; + } + + /** + * Updates the specified stat in the database, inserting it if necessary. + */ + protected void updateStat (int playerId, final Stat stat) + throws PersistenceException + { + final String uquery = "update STATS set STAT_DATA = ?" + + " where PLAYER_ID = " + playerId + " and STAT_CODE = " + stat.getCode(); + final String iquery = "insert into STATS (PLAYER_ID, STAT_CODE, " + + "STAT_DATA) values (" + playerId + ", " + stat.getCode() + ", ?)"; + final ByteArrayOutInputStream out = new ByteArrayOutInputStream(); + try { + stat.persistTo(new ObjectOutputStream(out), this); + } catch (IOException ioe) { + String errmsg = "Error serializing stat " + stat; + throw new PersistenceException(errmsg, ioe); + } + + // now update (or insert) the flattened data into the database + executeUpdate(new Operation() { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + PreparedStatement stmt = conn.prepareStatement(uquery); + try { + stmt.setBinaryStream(1, out.getInputStream(), out.size()); + if (stmt.executeUpdate() == 0) { + JDBCUtil.close(stmt); + stmt = conn.prepareStatement(iquery); + stmt.setBinaryStream(1, out.getInputStream(), out.size()); + JDBCUtil.checkedUpdate(stmt, 1); + } + return null; + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + /** Helper function for {@link #getStringCode}. */ + protected Integer assignStringCode (final Stat.Type type, final String value) + throws PersistenceException + { + return executeUpdate(new Operation() { + public Integer invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + for (int ii = 0; ii < 10; ii++) { + try { + int code = getNextCode(conn, type); + // DEBUG: uncomment this to test code collision + // if (ii == 0 && code > 0) { + // code = code-1; + // } + insertStringCode(conn, type, value, code); + return code; + + } catch (SQLException sqe) { + // if this is not a duplicate row exception, something is booched and we + // just fail + if (!liaison.isDuplicateRowException(sqe)) { + throw sqe; + } + + // if it is a duplicate row exception, possibly someone inserted our value + // before we could, in which case we can just look up the new mapping + int code = getCurrentCode(conn, type, value); + if (code != -1) { + log.info("Value collision assigning string code [type=" + type + + ", value=" + value + "]."); + return code; + } + + // otherwise someone used the code we were trying to use and we just need + // to loop around and get the next highest code + log.info("Code collision assigning string code [type=" + type + + ", value=" + value + "]."); + } + } + throw new SQLException("Unable to assign code after 10 attempts " + + "[type=" + type + ", value=" + value + "]"); + } + }); + } + + /** Helper function for {@link #assignStringCode}. */ + protected int getNextCode (Connection conn, Stat.Type type) + throws SQLException + { + return getCode(conn, "select MAX(CODE)+1 from STRING_CODES where STAT_CODE = " + + type.code()); + } + + /** Helper function for {@link #assignStringCode}. */ + protected int getCurrentCode (Connection conn, Stat.Type type, String value) + throws SQLException + { + return getCode(conn, "select CODE from STRING_CODES where STAT_CODE = " + type.code() + + " and VALUE = " + JDBCUtil.escape(value)); + } + + /** Helper function for {@link #getNextCode} and {@link #getCurrentCode}. */ + protected int getCode (Connection conn, String query) + throws SQLException + { + int code = -1; + Statement stmt = conn.createStatement(); + try { + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + code = rs.getInt(1); + } + } finally { + JDBCUtil.close(stmt); + } + return code; + } + + /** Helper function for {@link #assignStringCode}. */ + protected void insertStringCode (Connection conn, Stat.Type type, String value, int code) + throws SQLException + { + String query = "insert into STRING_CODES (STAT_CODE, VALUE, CODE) values(" + type.code() + + ", " + JDBCUtil.escape(value) + ", " + code + ")"; + Statement stmt = conn.createStatement(); + try { + int mods = stmt.executeUpdate(query); + if (mods != 1) { + throw new SQLException("Insertion failed to modify one row [type=" + type + + ", value=" + value + ", code=" + code + + ", mods=" + mods + "]"); + } + } finally { + JDBCUtil.close(stmt); + } + } + + /** Helper function used at repository startup. */ + protected void loadStringCodes (Stat.Type type) + throws PersistenceException + { + final String query = "select STAT_CODE, VALUE, CODE from STRING_CODES " + + ((type == null) ? "" : (" where STAT_CODE = " + type.code())); + execute(new Operation() { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Statement stmt = conn.createStatement(); + try { + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + mapStringCode(Stat.getType(rs.getInt(1)), rs.getString(2), rs.getInt(3)); + } + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + /** Helper function used at repository startup. */ + protected void mapStringCode (Stat.Type type, String value, int code) + { + HashMap fmap = _stringToCode.get(type); + if (fmap == null) { + _stringToCode.put(type, fmap = new HashMap()); + } + fmap.put(value, code); + HashIntMap rmap = _codeToString.get(type); + if (rmap == null) { + _codeToString.put(type, rmap = new HashIntMap()); + } + rmap.put(code, value); + } + + @Override // documentation inherited + protected void migrateSchema (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + JDBCUtil.createTableIfMissing(conn, "STATS", new String[] { + "PLAYER_ID INTEGER NOT NULL", + "STAT_CODE INTEGER NOT NULL", + "STAT_DATA BLOB NOT NULL", + "KEY (PLAYER_ID)", + "KEY (STAT_CODE)", + }, ""); + + JDBCUtil.createTableIfMissing(conn, "STRING_CODES", new String[] { + "STAT_CODE INTEGER NOT NULL", + "VALUE VARCHAR(255) NOT NULL", + "CODE INTEGER NOT NULL", + "UNIQUE (STAT_CODE, VALUE)", + "UNIQUE (STAT_CODE, CODE)", + }, ""); + } + + protected HashMap> _stringToCode = + new HashMap>(); + protected HashMap> _codeToString = + new HashMap>(); +}