Skip to content

Instantly share code, notes, and snippets.

@ken39arg
Last active December 1, 2017 11:16
Show Gist options
  • Save ken39arg/124596cee96685567b37f78305d8c61b to your computer and use it in GitHub Desktop.
Save ken39arg/124596cee96685567b37f78305d8c61b to your computer and use it in GitHub Desktop.
ISUCON7 MSAの最終コードと自分用メモ
package main
import (
"context"
"fmt"
"log"
"math/big"
"strconv"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/websocket"
"github.com/jmoiron/sqlx"
)
var (
bigInt1000 = big.NewInt(1000)
)
type Prices struct {
pricesMap map[int]*big.Int
mux *sync.RWMutex
}
type Powers struct {
powersMap map[int]*big.Int
mux *sync.RWMutex
}
type RoomTime struct {
time int64
mux *sync.Mutex
}
type RoomsTime struct {
timeMap map[string]*RoomTime
mux *sync.Mutex
}
func (rt *RoomsTime) getOrCreateRoomTime(name string) *RoomTime {
rt.mux.Lock()
defer rt.mux.Unlock()
var res *RoomTime
res, ok := rt.timeMap[name]
if ok {
return res
}
res = &RoomTime{
mux: new(sync.Mutex),
}
rt.timeMap[name] = res
return res
}
var (
roomTimes = RoomsTime{
timeMap: make(map[string]*RoomTime, 100000),
mux: new(sync.Mutex),
}
)
type GameRequest struct {
RequestID int `json:"request_id"`
Action string `json:"action"`
Time int64 `json:"time"`
// for addIsu
Isu string `json:"isu"`
// for buyItem
ItemID int `json:"item_id"`
CountBought int `json:"count_bought"`
}
type GameResponse struct {
RequestID int `json:"request_id"`
IsSuccess bool `json:"is_success"`
}
// 10進数の指数表記に使うデータ。JSONでは [仮数部, 指数部] という2要素配列になる。
type Exponential struct {
// Mantissa * 10 ^ Exponent
Mantissa int64
Exponent int64
}
func (n Exponential) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf("[%d,%d]", n.Mantissa, n.Exponent)), nil
}
type Adding struct {
RoomName string `json:"-" db:"room_name"`
Time int64 `json:"time" db:"time"`
Isu string `json:"isu" db:"isu"`
}
type Buying struct {
RoomName string `db:"room_name"`
ItemID int `db:"item_id"`
Ordinal int `db:"ordinal"`
Time int64 `db:"time"`
}
type Schedule struct {
Time int64 `json:"time"`
MilliIsu Exponential `json:"milli_isu"`
TotalPower Exponential `json:"total_power"`
}
type Item struct {
ItemID int `json:"item_id"`
CountBought int `json:"count_bought"`
CountBuilt int `json:"count_built"`
NextPrice Exponential `json:"next_price"`
Power Exponential `json:"power"`
Building []Building `json:"building"`
}
type OnSale struct {
ItemID int `json:"item_id"`
Time int64 `json:"time"`
}
type Building struct {
Time int64 `json:"time"`
CountBuilt int `json:"count_built"`
Power Exponential `json:"power"`
}
type GameStatus struct {
Time int64 `json:"time"`
Adding []Adding `json:"adding"`
Schedule []Schedule `json:"schedule"`
Items []Item `json:"items"`
OnSale []OnSale `json:"on_sale"`
}
type mItem struct {
ItemID int `db:"item_id"`
Power1 int64 `db:"power1"`
Power2 int64 `db:"power2"`
Power3 int64 `db:"power3"`
Power4 int64 `db:"power4"`
Price1 int64 `db:"price1"`
Price2 int64 `db:"price2"`
Price3 int64 `db:"price3"`
Price4 int64 `db:"price4"`
priceMux Prices `db:"-"`
powerMux Powers `db:"-"`
}
func (item *mItem) GetPower(count int) *big.Int {
mx := item.powerMux
mx.mux.RLock()
power, ok := mx.powersMap[count]
mx.mux.RUnlock()
if ok {
return power
}
mx.mux.Lock()
// power(x):=(cx+1)*d^(ax+b)
a := item.Power1
b := item.Power2
c := item.Power3
d := item.Power4
x := int64(count)
s := big.NewInt(c*x + 1)
t := new(big.Int).Exp(big.NewInt(d), big.NewInt(a*x+b), nil)
power = new(big.Int).Mul(s, t)
mx.powersMap[count] = power
mx.mux.Unlock()
return power
}
func (item *mItem) GetPrice(count int) *big.Int {
mx := item.priceMux
mx.mux.RLock()
price, ok := mx.pricesMap[count]
mx.mux.RUnlock()
if ok {
return price
}
mx.mux.Lock()
// price(x):=(cx+1)*d^(ax+b)
a := item.Price1
b := item.Price2
c := item.Price3
d := item.Price4
x := int64(count)
s := big.NewInt(c*x + 1)
t := new(big.Int).Exp(big.NewInt(d), big.NewInt(a*x+b), nil)
price = new(big.Int).Mul(s, t)
mx.pricesMap[count] = price
mx.mux.Unlock()
return price
}
func str2big(s string) *big.Int {
x := new(big.Int)
x.SetString(s, 10)
return x
}
func big2exp(n *big.Int) Exponential {
s := n.String()
if len(s) <= 15 {
return Exponential{n.Int64(), 0}
}
t, err := strconv.ParseInt(s[:15], 10, 64)
if err != nil {
log.Panic(err)
}
return Exponential{t, int64(len(s) - 15)}
}
func getCurrentTime() (int64, error) {
return time.Now().UnixNano() / int64(time.Millisecond), nil
}
// 部屋のロックを取りタイムスタンプを更新する
//
// トランザクション開始後この関数を呼ぶ前にクエリを投げると、
// そのトランザクション中の通常のSELECTクエリが返す結果がロック取得前の
// 状態になることに注意 (keyword: MVCC, repeatable read).
func updateRoomTime(tx *sqlx.Tx, roomName string, reqTime int64) (int64, bool, func()) {
// See page 13 and 17 in https://www.slideshare.net/ichirin2501/insert-51938787
rt := roomTimes.getOrCreateRoomTime(roomName)
rt.mux.Lock()
fn := func() {
rt.mux.Unlock()
}
roomTime := rt.time
currentTime, _ := getCurrentTime()
if roomTime > currentTime {
//log.Println("room time is future")
return 0, false, fn
}
if reqTime != 0 {
if reqTime < currentTime {
//log.Println("reqTime is past")
return 0, false, fn
}
}
rt.time = currentTime
return currentTime, true, fn
}
func addIsu(roomName string, reqIsu *big.Int, reqTime int64) bool {
g := gameLocks[roomName]
if g == nil {
log.Println("gameState is missing")
return false
}
g.m.Lock()
defer g.m.Unlock()
tx, err := db.Beginx()
if err != nil {
log.Println(err)
return false
}
currentTime, ok, fn := updateRoomTime(tx, roomName, reqTime)
defer fn()
if !ok {
tx.Rollback()
return false
}
var isUpdate bool
var add Adding
alen := len(g.adding)
if alen > 0 {
add = g.adding[alen-1]
if add.Time == reqTime {
isUpdate = true
}
}
if !isUpdate {
add = Adding{
RoomName: roomName,
Time: reqTime,
Isu: "0",
}
}
isu := str2big(add.Isu)
isu.Add(isu, reqIsu)
add.Isu = isu.String()
if isUpdate {
_, err = tx.Exec("UPDATE adding SET isu = ? WHERE room_name = ? AND time = ?", add.Isu, roomName, reqTime)
} else {
_, err = tx.Exec("INSERT INTO adding(room_name, time, isu) VALUES (?, ?, ?)", roomName, reqTime, add.Isu)
}
if err != nil {
log.Println(err)
tx.Rollback()
return false
}
if err := tx.Commit(); err != nil {
log.Println(err)
return false
}
if isUpdate {
g.adding[alen-1] = add
} else {
g.adding = append(g.adding, add)
}
// old adding
fixed := big.NewInt(0)
addingNew := make([]Adding, 0, alen)
for _, a := range g.adding {
if currentTime < a.Time {
addingNew = append(addingNew, a)
} else {
if len(addingNew) == 0 {
addingNew = append(addingNew, a)
}
addingNew[0].Time = a.Time
fixed.Add(fixed, str2big(a.Isu))
}
}
if len(addingNew) < alen {
addingNew[0].Isu = fixed.String()
g.adding = addingNew
}
return true
}
func buyItem(roomName string, itemID int, countBought int, reqTime int64) bool {
g := gameLocks[roomName]
if g == nil {
log.Println("gameState is missing")
return false
}
g.m.Lock()
defer g.m.Unlock()
tx, err := db.Beginx()
if err != nil {
log.Println(err)
return false
}
_, ok, fn := updateRoomTime(tx, roomName, reqTime)
defer fn()
if !ok {
tx.Rollback()
return false
}
var countBuying int
err = tx.Get(&countBuying, "SELECT COUNT(*) FROM buying WHERE room_name = ? AND item_id = ?", roomName, itemID)
if err != nil {
log.Println(err)
tx.Rollback()
return false
}
if countBuying != countBought {
tx.Rollback()
log.Println(roomName, itemID, countBought+1, " is already bought")
return false
}
totalMilliIsu := new(big.Int)
for _, a := range g.adding {
if a.Time <= reqTime {
totalMilliIsu.Add(totalMilliIsu, new(big.Int).Mul(str2big(a.Isu), bigInt1000))
}
}
for _, b := range g.buying {
item := mItems[b.ItemID]
cost := new(big.Int).Mul(item.GetPrice(b.Ordinal), bigInt1000)
totalMilliIsu.Sub(totalMilliIsu, cost)
if b.Time <= reqTime {
gain := new(big.Int).Mul(item.GetPower(b.Ordinal), big.NewInt(reqTime-b.Time))
totalMilliIsu.Add(totalMilliIsu, gain)
}
}
item := mItems[itemID]
need := new(big.Int).Mul(item.GetPrice(countBought+1), bigInt1000)
if totalMilliIsu.Cmp(need) < 0 {
log.Println("not enough")
tx.Rollback()
return false
}
_, err = tx.Exec("INSERT INTO buying(room_name, item_id, ordinal, time) VALUES(?, ?, ?, ?)", roomName, itemID, countBought+1, reqTime)
if err != nil {
log.Println(err)
tx.Rollback()
return false
}
if err := tx.Commit(); err != nil {
log.Println(err)
return false
}
g.buying = append(g.buying, Buying{
RoomName: roomName,
ItemID: itemID,
Ordinal: countBought + 1,
Time: reqTime,
})
return true
}
func getStatus(roomName string) (*GameStatus, error) {
g := gameLocks[roomName]
if g == nil {
return nil, fmt.Errorf("gameState is missing")
}
currentTime, ok, fn := updateRoomTime(nil, roomName, 0)
defer fn()
if !ok {
return nil, fmt.Errorf("updateRoomTime failure")
}
status, err := calcStatus(currentTime, mItems, g.adding, g.buying)
if err != nil {
return nil, err
}
// calcStatusに時間がかかる可能性があるので タイムスタンプを取得し直す
latestTime, err := getCurrentTime()
if err != nil {
return nil, err
}
status.Time = latestTime
return status, err
}
func calcStatus(currentTime int64, mItems map[int]mItem, addings []Adding, buyings []Buying) (*GameStatus, error) {
var (
// 1ミリ秒に生産できる椅子の単位をミリ椅子とする
totalMilliIsu = big.NewInt(0)
totalPower = big.NewInt(0)
itemPower = map[int]*big.Int{} // ItemID => Power
itemPrice = map[int]*big.Int{} // ItemID => Price
itemPriceForCmp = map[int]*big.Int{}
itemOnSale = map[int]int64{} // ItemID => OnSale
itemBuilt = map[int]int{} // ItemID => BuiltCount
itemBought = map[int]int{} // ItemID => CountBought
itemBuilding = map[int][]Building{} // ItemID => Buildings
itemPower0 = map[int]Exponential{} // ItemID => currentTime における Power
itemBuilt0 = map[int]int{} // ItemID => currentTime における BuiltCount
addingAt = map[int64]Adding{} // Time => currentTime より先の Adding
buyingAt = map[int64][]Buying{} // Time => currentTime より先の Buying
)
for itemID := range mItems {
itemPower[itemID] = big.NewInt(0)
itemBuilding[itemID] = []Building{}
}
for _, a := range addings {
// adding は adding.time に isu を増加させる
if a.Time <= currentTime {
totalMilliIsu.Add(totalMilliIsu, new(big.Int).Mul(str2big(a.Isu), bigInt1000))
} else {
addingAt[a.Time] = a
}
}
for _, b := range buyings {
// buying は 即座に isu を消費し buying.time からアイテムの効果を発揮する
itemBought[b.ItemID]++
m := mItems[b.ItemID]
totalMilliIsu.Sub(totalMilliIsu, new(big.Int).Mul(m.GetPrice(b.Ordinal), bigInt1000))
if b.Time <= currentTime {
itemBuilt[b.ItemID]++
power := m.GetPower(itemBought[b.ItemID])
totalMilliIsu.Add(totalMilliIsu, new(big.Int).Mul(power, big.NewInt(currentTime-b.Time)))
totalPower.Add(totalPower, power)
itemPower[b.ItemID].Add(itemPower[b.ItemID], power)
} else {
buyingAt[b.Time] = append(buyingAt[b.Time], b)
}
}
for _, m := range mItems {
itemPower0[m.ItemID] = big2exp(itemPower[m.ItemID])
itemBuilt0[m.ItemID] = itemBuilt[m.ItemID]
price := m.GetPrice(itemBought[m.ItemID] + 1)
priceForCmp := new(big.Int).Mul(price, bigInt1000)
itemPrice[m.ItemID] = price
itemPriceForCmp[m.ItemID] = priceForCmp
if 0 <= totalMilliIsu.Cmp(priceForCmp) {
itemOnSale[m.ItemID] = 0 // 0 は 時刻 currentTime で購入可能であることを表す
}
}
schedule := []Schedule{
Schedule{
Time: currentTime,
MilliIsu: big2exp(totalMilliIsu),
TotalPower: big2exp(totalPower),
},
}
// currentTime から 1000 ミリ秒先までシミュレーションする
for t := currentTime + 1; t <= currentTime+1000; t++ {
totalMilliIsu.Add(totalMilliIsu, totalPower)
updated := false
// 時刻 t で発生する adding を計算する
if a, ok := addingAt[t]; ok {
updated = true
totalMilliIsu.Add(totalMilliIsu, new(big.Int).Mul(str2big(a.Isu), bigInt1000))
}
// 時刻 t で発生する buying を計算する
if _, ok := buyingAt[t]; ok {
updated = true
updatedID := map[int]bool{}
for _, b := range buyingAt[t] {
m := mItems[b.ItemID]
updatedID[b.ItemID] = true
itemBuilt[b.ItemID]++
power := m.GetPower(b.Ordinal)
itemPower[b.ItemID].Add(itemPower[b.ItemID], power)
totalPower.Add(totalPower, power)
}
for id := range updatedID {
itemBuilding[id] = append(itemBuilding[id], Building{
Time: t,
CountBuilt: itemBuilt[id],
Power: big2exp(itemPower[id]),
})
}
}
if updated {
schedule = append(schedule, Schedule{
Time: t,
MilliIsu: big2exp(totalMilliIsu),
TotalPower: big2exp(totalPower),
})
}
// 時刻 t で購入可能になったアイテムを記録する
for itemID := range mItems {
if _, ok := itemOnSale[itemID]; ok {
continue
}
if 0 <= totalMilliIsu.Cmp(itemPriceForCmp[itemID]) {
itemOnSale[itemID] = t
}
}
}
gsAdding := []Adding{}
for _, a := range addingAt {
gsAdding = append(gsAdding, a)
}
gsItems := []Item{}
for itemID, _ := range mItems {
gsItems = append(gsItems, Item{
ItemID: itemID,
CountBought: itemBought[itemID],
CountBuilt: itemBuilt0[itemID],
NextPrice: big2exp(itemPrice[itemID]),
Power: itemPower0[itemID],
Building: itemBuilding[itemID],
})
}
gsOnSale := []OnSale{}
for itemID, t := range itemOnSale {
gsOnSale = append(gsOnSale, OnSale{
ItemID: itemID,
Time: t,
})
}
return &GameStatus{
Adding: gsAdding,
Schedule: schedule,
Items: gsItems,
OnSale: gsOnSale,
}, nil
}
func serveGameConn(ws *websocket.Conn, roomName string) {
defer ws.Close()
status, err := getStatus(roomName)
if err != nil {
log.Println(err)
return
}
err = ws.WriteJSON(status)
if err != nil {
log.Println(err)
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chReq := make(chan GameRequest)
go func() {
defer cancel()
for {
req := GameRequest{}
err := ws.ReadJSON(&req)
if err != nil {
log.Println(err)
return
}
select {
case chReq <- req:
case <-ctx.Done():
return
}
}
}()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case req := <-chReq:
//log.Println(req)
success := false
switch req.Action {
case "addIsu":
success = addIsu(roomName, str2big(req.Isu), req.Time)
case "buyItem":
success = buyItem(roomName, req.ItemID, req.CountBought, req.Time)
default:
log.Println("Invalid Action")
return
}
if success {
// GameResponse を返却する前に 反映済みの GameStatus を返す
status, err := getStatus(roomName)
if err != nil {
log.Println(err)
return
}
err = ws.WriteJSON(status)
if err != nil {
log.Println(err)
return
}
}
err := ws.WriteJSON(GameResponse{
RequestID: req.RequestID,
IsSuccess: success,
})
if err != nil {
log.Println(err)
return
}
case <-ticker.C:
status, err := getStatus(roomName)
if err != nil {
log.Println(err)
return
}
err = ws.WriteJSON(status)
if err != nil {
log.Println(err)
return
}
case <-ctx.Done():
return
}
}
}
var mItems map[int]mItem
func initMItems() error {
var items []mItem
err := db.Select(&items, "SELECT * FROM m_item")
if err != nil {
return err
}
mItems = make(map[int]mItem, len(items))
for i := range items {
items[i].priceMux = Prices{
pricesMap: make(map[int]*big.Int),
mux: new(sync.RWMutex),
}
items[i].powerMux = Powers{
powersMap: make(map[int]*big.Int),
mux: new(sync.RWMutex),
}
mItems[items[i].ItemID] = items[i]
}
return nil
}
type gameState struct {
m *sync.Mutex
lasttime int64
adding []Adding
buying []Buying
}
var glock = new(sync.Mutex)
var gameLocks = map[string]*gameState{}
func initGameState(roomName string) {
glock.Lock()
defer glock.Unlock()
if _, ok := gameLocks[roomName]; !ok {
//log.Printf("initGameState[%s]", roomName)
addings := []Adding{}
if err := db.Select(&addings, "SELECT * FROM adding WHERE room_name = ? ORDER BY time", roomName); err != nil {
log.Printf("select adding failed %s", err)
}
buyings := []Buying{}
if err := db.Select(&buyings, "SELECT * FROM buying WHERE room_name = ? ORDER BY time", roomName); err != nil {
log.Printf("select buying failed %s", err)
}
gameLocks[roomName] = &gameState{
m: new(sync.Mutex),
lasttime: 0,
adding: addings,
buying: buyings,
}
}
}
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"sync"
"time"
"github.com/go-sql-driver/mysql"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/jmoiron/sqlx"
)
func IsMySQLDuplicateError(err error) bool {
if mysqlError, ok := err.(*mysql.MySQLError); ok {
return mysqlError.Number == 1062
}
return false
}
type RoomsMux struct {
idMap map[string]int
mux *sync.RWMutex
}
func (rm *RoomsMux) getID(name string) (id int, ok bool) {
rm.mux.RLock()
id, ok = rm.idMap[name]
rm.mux.RUnlock()
return
}
func (rm *RoomsMux) setID(name string, id int) {
rm.mux.Lock()
rm.idMap[name] = id
rm.mux.Unlock()
}
var (
rooms = RoomsMux{
idMap: make(map[string]int, 100000),
mux: new(sync.RWMutex),
}
)
var (
hosts = []string{
"app0051.isu7f.k0y.org",
"app0052.isu7f.k0y.org",
"app0053.isu7f.k0y.org",
"app0051.isu7f.k0y.org",
"app0052.isu7f.k0y.org",
"app0053.isu7f.k0y.org",
"app0054.isu7f.k0y.org",
}
)
var (
db *sqlx.DB
)
func initDB() {
db_host := os.Getenv("ISU_DB_HOST")
if db_host == "" {
db_host = "127.0.0.1"
}
db_port := os.Getenv("ISU_DB_PORT")
if db_port == "" {
db_port = "3306"
}
db_user := os.Getenv("ISU_DB_USER")
if db_user == "" {
db_user = "root"
}
db_password := os.Getenv("ISU_DB_PASSWORD")
if db_password != "" {
db_password = ":" + db_password
}
dsn := fmt.Sprintf("%s%s@tcp(%s:%s)/isudb?parseTime=true&loc=Local&charset=utf8mb4",
db_user, db_password, db_host, db_port)
log.Printf("Connecting to db: %q", dsn)
db, _ = sqlx.Connect("mysql", dsn)
for {
err := db.Ping()
if err == nil {
break
}
log.Println(err)
time.Sleep(time.Second * 3)
}
db.SetMaxOpenConns(20)
db.SetConnMaxLifetime(5 * time.Minute)
log.Printf("Succeeded to connect db.")
if err := initMItems(); err != nil {
log.Printf("error initMItems")
}
}
func getInitializeHandler(w http.ResponseWriter, r *http.Request) {
db.MustExec("TRUNCATE TABLE adding")
db.MustExec("TRUNCATE TABLE buying")
db.MustExec("TRUNCATE TABLE room_time")
w.WriteHeader(204)
}
func getRoomHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
roomName := vars["room_name"]
path := "/ws/" + url.PathEscape(roomName)
id, ok := rooms.getID(roomName)
if !ok {
res, err := db.Exec("INSERT INTO room_websocket(room_name) VALUES(?)", roomName)
if err != nil {
if IsMySQLDuplicateError(err) {
err := db.Get(&id, "select id from room_websocket where room_name = ?", roomName)
if err != nil {
log.Println(err)
return
}
} else {
log.Println(err)
return
}
} else {
var id64 int64
id64, err = res.LastInsertId()
if err != nil {
log.Println(err)
return
}
id = int(id64)
rooms.setID(roomName, id)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Host string `json:"host"`
Path string `json:"path"`
}{
Host: hosts[id%len(hosts)],
Path: path,
})
}
func wsGameHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
roomName := vars["room_name"]
initGameState(roomName)
ws, err := websocket.Upgrade(w, r, nil, 1024, 1024)
if _, ok := err.(websocket.HandshakeError); ok {
log.Println("Failed to upgrade", err)
return
}
go serveGameConn(ws, roomName)
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
initDB()
r := mux.NewRouter()
r.HandleFunc("/initialize", getInitializeHandler)
r.HandleFunc("/room/", getRoomHandler)
r.HandleFunc("/room/{room_name}", getRoomHandler)
r.HandleFunc("/ws/", wsGameHandler)
r.HandleFunc("/ws/{room_name}", wsGameHandler)
r.PathPrefix("/").Handler(http.FileServer(http.Dir("../public/")))
//go http.ListenAndServe(":8080", nil)
log.Fatal(http.ListenAndServe(":5000", handlers.LoggingHandler(os.Stderr, r)))
}
{
"name": "MSA",
"data": {
"2017-11-25T11:08:48": 7211,
"2017-11-25T11:17:34": 4300,
"2017-11-25T11:22:34": 4790,
"2017-11-25T12:01:36": 5103,
"2017-11-25T12:29:29": 10401,
"2017-11-25T12:56:49": 8695,
"2017-11-25T12:59:45": 4586,
"2017-11-25T13:04:03": 9784,
"2017-11-25T13:31:27": 5460,
"2017-11-25T13:33:33": 3731,
"2017-11-25T13:35:29": 4561,
"2017-11-25T13:39:15": 5397,
"2017-11-25T13:44:02": 8669,
"2017-11-25T13:48:00": 8337,
"2017-11-25T13:54:31": 6658,
"2017-11-25T14:05:23": 1491,
"2017-11-25T14:08:30": 4836,
"2017-11-25T14:45:35": 4987,
"2017-11-25T14:47:46": 8654,
"2017-11-25T15:04:19": 7700,
"2017-11-25T15:09:50": 7746,
"2017-11-25T15:26:45": 8021,
"2017-11-25T15:28:47": 9259,
"2017-11-25T15:31:35": 8572,
"2017-11-25T15:33:16": 4730,
"2017-11-25T15:45:17": 7477,
"2017-11-25T15:51:37": 7012,
"2017-11-25T15:53:03": 7921,
"2017-11-25T15:55:51": 7796,
"2017-11-25T16:02:11": 5790,
"2017-11-25T16:04:12": 6404,
"2017-11-25T16:09:28": 7795,
"2017-11-25T16:20:45": 7928,
"2017-11-25T16:23:23": 8744,
"2017-11-25T16:31:37": 5773,
"2017-11-25T16:36:27": 48745,
"2017-11-25T16:43:45": 57103,
"2017-11-25T16:50:27": 60396,
"2017-11-25T17:09:21": 61750,
"2017-11-25T17:24:53": 59491,
"2017-11-25T17:36:59": 62847,
"2017-11-25T17:41:01": 63404,
"2017-11-25T17:45:11": 62871,
"2017-11-25T17:50:53": 65218,
"2017-11-25T18:07:04": 49934,
"2017-11-25T18:09:24": 65077,
"2017-11-25T18:11:31": 64631,
"2017-11-25T18:14:24": 62049,
"2017-11-25T18:17:18": 60943,
"2017-11-25T18:18:45": 64847
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment