summaryrefslogtreecommitdiff
path: root/worker.js
diff options
context:
space:
mode:
Diffstat (limited to 'worker.js')
-rw-r--r--worker.js351
1 files changed, 276 insertions, 75 deletions
diff --git a/worker.js b/worker.js
index 0c407eb..100adcd 100644
--- a/worker.js
+++ b/worker.js
@@ -4,22 +4,24 @@
var amqp = require("amqplib"),
Seq = require("sequelize"),
+ snakeCaseKeys = require("snakecase-keys"),
+ item_name_map = require("../orm/items"),
sleep = require("sleep-promise");
-var RABBITMQ_URI = process.env.RABBITMQ_URI || "amqp://localhost",
- DATABASE_URI = process.env.DATABASE_URI || "sqlite:///db.sqlite",
- BATCHSIZE = process.env.PROCESSOR_BATCH || 50 * (1 + 5), // matches + players
- IDLE_TIMEOUT = process.env.PROCESSOR_IDLETIMEOUT || 500; // ms
+var RABBITMQ_URI = process.env.RABBITMQ_URI,
+ DATABASE_URI = process.env.DATABASE_URI,
+ BATCHSIZE = parseInt(process.env.PROCESSOR_BATCH) || 50 * (1 + 5), // matches + players
+ IDLE_TIMEOUT = parseFloat(process.env.PROCESSOR_IDLETIMEOUT) || 500; // ms
(async () => {
let seq, model, rabbit, ch;
while (true) {
try {
- seq = new Seq(DATABASE_URI, { logging: () => {} }),
- rabbit = await amqp.connect(RABBITMQ_URI),
+ seq = new Seq(DATABASE_URI, { logging: () => {} });
+ rabbit = await amqp.connect(RABBITMQ_URI);
ch = await rabbit.createChannel();
- await ch.assertQueue("compile", {durable: true});
+ await ch.assertQueue("process", {durable: true});
await ch.assertQueue("analyze", {durable: true});
break;
} catch (err) {
@@ -27,47 +29,255 @@ var RABBITMQ_URI = process.env.RABBITMQ_URI || "amqp://localhost",
await sleep(5000);
}
}
+
model = require("../orm/model")(seq, Seq);
let queue = [],
timer = undefined;
+ let item_db_map = {}, // "Halcyon Potion" to id
+ hero_db_map = {}, // "*SAW*" to id
+ series_db_map = {}, // date to series id
+ game_mode_db_map = {}, // "ranked" to id
+ role_db_map = {}; // "captain" to id
+
+ /* recreate for debugging
+ await seq.query("SET FOREIGN_KEY_CHECKS=0");
+ await seq.sync({force: true});
+ */
await seq.sync();
+ // TODO instead of object, use Map
+ await Promise.all([
+ model.Item.findAll()
+ .map((item) => item_db_map[item.name] = item.id),
+ model.Hero.findAll()
+ .map((hero) => hero_db_map[hero.name] = hero.id),
+ model.Series.findAll()
+ .map((series) => series_db_map[series.name] = series.id),
+ model.GameMode.findAll()
+ .map((mode) => game_mode_db_map[mode.name] = mode.id),
+ model.Role.findAll()
+ .map((role) => role_db_map[role.name] = role.id)
+ ]);
// as long as the queue is filled, msg are not ACKed
// server sends as long as there are less than `prefetch` unACKed
await ch.prefetch(BATCHSIZE);
- ch.consume("compile", async (msg) => {
+ ch.consume("process", async (msg) => {
queue.push(msg);
// fill queue until batchsize or idle
if (timer === undefined)
- timer = setTimeout(process, IDLE_TIMEOUT)
+ timer = setTimeout(process, IDLE_TIMEOUT);
if (queue.length == BATCHSIZE)
await process();
}, { noAck: false });
async function process() {
- console.log("compiling batch", queue.length);
+ console.log("processing batch", queue.length);
// clean up to allow processor to accept while we wait for db
- let msgs = queue.slice();
- queue = [];
clearTimeout(timer);
timer = undefined;
+ let msgs = queue;
+ queue = [];
+
+ // helper to convert API response into flat JSON
+ // db structure is (almost) 1:1 the API structure
+ // so we can insert the flat API response as-is
+ function flatten(obj) {
+ let attrs = obj.attributes || {},
+ stats = attrs.stats || {},
+ o = Object.assign({}, obj, attrs, stats);
+ o.api_id = o.id; // rename
+ delete o.id;
+ delete o.type;
+ delete o.attributes;
+ delete o.stats;
+ delete o.relationships;
+ return o;
+ }
+
+ // helper: true if object is not in record arr
+ // also, sort out invalid objects (undefined or null)
+ let is_in = (arr, obj) => obj == undefined ||
+ arr.map((o) => o.api_id).indexOf(obj.api_id) > -1;
+
- // aggregate & bulk insert
- let participant_stats_records = [],
- player_updates = []; // [[what, where]]
+ // we aggregate record objects to do a bulk insert
+ let match_records = [],
+ roster_records = [],
+ participant_records = [],
+ participant_stats_records = [],
+ player_records = [],
+ asset_records = [],
+ participant_item_records = [];
+
+ // populate `_records`
+ // data from `/player`
+ msgs.filter((m) => m.properties.type == "player").map((msg) => {
+ let players = JSON.parse(msg.content);
+ players.map((p) => {
+ let player = flatten(p);
+ console.log("processing player", player.name);
+ if (!is_in(player_records, player))
+ player_records.push(player);
+ });
+ });
- // processor sends to queue with a custom "type" so compiler can filter
- // m.content: player.api_id
- let players = msgs.filter((m) => m.properties.type == "player").map(
- (m) => JSON.parse(m.content)).filter((p) => p != undefined),
- participants = msgs.filter((m) => m.properties.type == "participant").map(
- (m) => JSON.parse(m.content)).filter((p) => p != undefined);
+ // data from `/matches`
+ msgs.filter((m) => m.properties.type == "match").map((msg) => {
+ let match = JSON.parse(msg.content);
+ console.log("processing match", match.id);
+ // flatten jsonapi nested response into our db structure-like shape
+ // also, push missing fields and snakecasify
+ match.rosters = match.rosters.map((roster) => {
+ roster.matchApiId = match.id;
+ roster.attributes.shardId = match.attributes.shardId;
+ roster.createdAt = match.createdAt;
+
+ roster.participants = roster.participants.map((participant) => {
+ participant.attributes.shardId = roster.attributes.shardId;
+ participant.rosterApiId = roster.id;
+ participant.createdAt = roster.createdAt;
+ participant.playerApiId = participant.player.id;
+
+ // API bug fixes
+ // items on AFK is `null` not `{}`
+ participant.attributes.stats.itemGrants = participant.attributes.stats.itemGrants || {};
+ participant.attributes.stats.itemSells = participant.attributes.stats.itemSells || {};
+ participant.attributes.stats.itemUses = participant.attributes.stats.itemUses || {};
+ // jungle_kills is `null` in BR
+ participant.attributes.stats.jungleKills = participant.attributes.stats.jungleKills || 0;
+
+ // map items: names/id -> name -> db
+ let itms = [],
+ item_use = (arr, action) =>
+ arr.map((item) => { return {
+ participant_api_id: participant.id,
+ item_id: item_db_map[item_name_map[item]],
+ action: action
+ } }),
+ item_arr_from_obj = (obj) =>
+ [].concat(... // 3 flatten
+ Object.entries(obj).map( // 1 map over (key, value)
+ (tuple) => Array(tuple[1]).fill(tuple[0]))) // 2 create Array [key] * value
+
+ itms = itms.concat(item_use(participant.attributes.stats.items, "final"));
+ itms = itms.concat(item_use(item_arr_from_obj(participant.attributes.stats.itemGrants), "grant"));
+ itms = itms.concat(item_use(item_arr_from_obj(participant.attributes.stats.itemUses), "use"));
+ itms = itms.concat(item_use(item_arr_from_obj(participant.attributes.stats.itemSells), "sell"));
+
+ // for debugging:
+ let items_missing_name = [].concat(...
+ Object.keys(participant.attributes.stats.itemGrants),
+ participant.attributes.stats.items)
+ .filter((i) => Object.keys(item_name_map).indexOf(i) == -1);
+ if (items_missing_name.length > 0) console.error("missing API name -> name mapping for", items_missing_name);
+
+ let items_missing_db = [].concat(...
+ Object.keys(participant.attributes.stats.itemGrants),
+ participant.attributes.stats.items)
+ .filter((i) => Object.keys(item_db_map).indexOf(item_name_map[i]) == -1);
+ if (items_missing_db.length > 0) console.error("missing name -> DB ID mapping for", items_missing_db);
+
+ // redefine participant.items for our custom map
+ participant.attributes.stats.participant_items = itms;
+
+ participant.player.attributes.shardId = participant.attributes.shardId;
+ participant.player = flatten(participant.player);
+ return flatten(participant);
+ });
+ return flatten(roster);
+ });
+ match.assets = match.assets.map((asset) => {
+ asset.matchApiId = match.id;
+ asset.attributes.shardId = match.attributes.shardId;
+ return flatten(asset);
+ });
+ match = snakeCaseKeys(flatten(match));
+
+ // after conversion, create the array of records
+ // there is a low chance of a match being duplicated in a batch,
+ // skip it and its children
+ if (!is_in(match_records, match)) {
+ match_records.push(match);
+ match.rosters.map((r) => {
+ roster_records.push(r);
+ r.participants.map((p) => {
+ let p_pstats = calculate_participant_stats(match, r, p);
+ // participant gets split into participant and p_stats
+ participant_records.push(p_pstats[0]);
+ participant_stats_records.push(p_pstats[1]);
+ // deduplicate player
+ // in a batch, it is very likely that players are duplicated
+ // so this improves performance a bit
+ if (!is_in(player_records, p.player)) player_records.push(p.player);
+ p.participant_items.map((i) => {
+ participant_item_records.push(i);
+ });
+ });
+ });
+ match.assets.map((a) => {
+ asset_records.push(a);
+ });
+ }
+ });
+
+ // now access db
+ try {
+ console.log("inserting batch into db");
+ // upsert whole batch in parallel
+ await seq.transaction({ autocommit: false }, async (transaction) => {
+ await Promise.all([
+ model.Match.bulkCreate(match_records, {
+ include: [ model.Roster, model.Asset ],
+ updateOnDuplicate: [], // all
+ transaction: transaction
+ }),
+ model.Roster.bulkCreate(roster_records, {
+ include: [ model.Roster ],
+ updateOnDuplicate: [],
+ transaction: transaction
+ }),
+ model.Participant.bulkCreate(participant_records, {
+ include: [ model.Player ],
+ updateOnDuplicate: [],
+ transaction: transaction
+ }),
+ model.ParticipantStats.bulkCreate(participant_stats_records, {
+ include: [ model.Participant ],
+ updateOnDuplicate: [],
+ transaction: transaction
+ }),
+ model.Player.bulkCreate(player_records, {
+ updateOnDuplicate: [],
+ transaction: transaction
+ }),
+ model.ItemParticipant.bulkCreate(participant_item_records, {
+ include: [ model.Participant ],
+ updateOnDuplicate: [],
+ transaction: transaction
+ }),
+ model.Asset.bulkCreate(asset_records, {
+ updateOnDuplicate: [],
+ transaction: transaction
+ })
+ ]);
+ });
+ await Promise.all(msgs.map((m) => ch.ack(m)) );
+ } catch (err) {
+ // this should only happen for Deadlocks in prod
+ // it *must not* fail due to broken schema or missing dependency
+ // TODO: eliminate such cases earlier in the chain
+ // and immediately NACK those broken matches, requeueing only the rest
+ console.error(err);
+ await Promise.all(msgs.map((m) => ch.nack(m, true)) ); // requeue
+ return; // give up
+ }
+ /*
// collect information and populate _record arrays
await Promise.all([
// collect player information
@@ -99,25 +309,6 @@ var RABBITMQ_URI = process.env.RABBITMQ_URI || "amqp://localhost",
{ where: { api_id: player_api_id } }
]);
})),
- // calculate participant fields
- Promise.all(participants.map(async (api_participant) => {
- participant_stats_records.push(calculate_stats(
- await model.Participant.findOne({
- where: { api_id: api_participant.api_id },
- include: [ {
- model: model.Roster,
- include: [
- model.Match
- ]
- }, {
- model: model.ItemParticipant,
- as: "items",
- include: [ model.Item ]
- }
- ]
- })
- ))
- }))
]);
// load records into db
@@ -125,22 +316,17 @@ var RABBITMQ_URI = process.env.RABBITMQ_URI || "amqp://localhost",
console.log("inserting batch into db");
await seq.transaction({ autocommit: false }, async (transaction) => {
await Promise.all([
- model.ParticipantStats.bulkCreate(participant_stats_records, {
- updateOnDuplicate: [], // all
- transaction: transaction
- }),
player_updates.map(async (pu) =>
await model.Player.update(pu[0], pu[1]))
]);
});
- } catch (err) {
- // this should only happen for deadlocks or non-data related issues
- console.error(err);
- await Promise.all(msgs.map((m) => ch.nack(m, true)) ); // requeue
- return; // give up
- }
- console.log("acking batch");
- await Promise.all(msgs.map((m) => ch.ack(m)) );
+ */
+
+ // notify web
+ await Promise.all(player_records.map(async (p) =>
+ await ch.publish("amq.topic", "player." + p.name, new Buffer("matches_update")) ));
+ if (match_records.length > 0)
+ await ch.publish("amq.topic", "global", new Buffer("matches_update"));
// notify analyzer
Promise.all(participant_stats_records.map(async (p) =>
@@ -149,36 +335,51 @@ var RABBITMQ_URI = process.env.RABBITMQ_URI || "amqp://localhost",
type: "participant"
})
));
-
- // notify web
- await Promise.all([
- Promise.all(players.map(async (p) => await ch.publish("amq.topic", "player." + p.name,
- new Buffer("stats_update")) )),
- Promise.all(participant_stats_records.map(async (p) => await ch.publish("amq.topic",
- "participant." + p.participant_api_id, new Buffer("stats_update")) ))
- ]);
}
- // based on the participant db record from the end of the match,
- // calculate a participant_stats record and return it
- function calculate_stats(participant) {
- if (participant == null) { console.error("got nonexisting participant!"); return; }
- let participant_stats = {};
- participant_stats.participant_api_id = participant.get("api_id");
+ // Split participant API data into participant and participant_stats
+ // Should not need to query db here.
+ function calculate_participant_stats(match, roster, participant) {
+ let p_s = {}, // participant_stats_record
+ p = {}; // participant_record
- participant_stats.kills = participant.get("kills");
+ // meta
+ p_s.participant_api_id = participant.api_id;
+ p_s.final = true; // these are the stats at the end of the match
+ p_s.updated_at = new Date();
+ p_s.created_at = new Date(); // TODO set to match.created_at+match.duration
- if (participant.roster.get("hero_kills") == 0) participant_stats.kill_participation = 0;
- else participant_stats.kill_participation = participant.get("kills") / participant.roster.get("hero_kills");
+ // attributes to copy from API to participant
+ // these don't change over the duration of the match
+ // (or aren't in Telemetry)
+ ["api_id", "shard_id", "player_api_id", "roster_api_id",
+ "winner", "went_afk", "first_afk_time",
+ "skin_key", "skill_tier", "level",
+ "karma_level", "actor",
+ "farm"].map((attr) =>
+ p[attr] = participant[attr]);
- participant_stats.sustain_score = participant.items.reduce((score, item) => {
- if (item.get("action") == "final") {
- if (["Eve of Harvest", "Serpent Mask"].indexOf(item.item.get("name")) != -1)
- return score + 20;
- }
+ // attributes to copy from API to participant_stats
+ // with Telemetry, these will be calculated in intervals
+ ["kills", "deaths", "assists", "minion_kills",
+ "jungle_kills", "non_jungle_minion_kills",
+ "crystal_mine_captures", "gold_mine_captures",
+ "kraken_captures", "turret_captures",
+ "gold"].map((attr) =>
+ p_s[attr] = participant[attr]);
+
+ // traits calculations
+ if (roster.hero_kills == 0) p_s.kill_participation = 0;
+ else p_s.kill_participation = p_s.kills / roster.hero_kills;
+
+ p_s.sustain_score = participant.items.reduce((score, item) => {
+ // items[], itemGrants{}, itemUse{}, itemSells{} are the API objects
+ // old item names to clean names need to be mapped via `item_name_map[oldname]`
+ if (["Eve of Harvest", "Serpent Mask"].indexOf(item_name_map[item]) != -1)
+ return score + 20;
return score;
}, 0);
- return participant_stats;
+ return [p, p_s];
}
})();