Minetrack/lib/database.js

361 lines
11 KiB
JavaScript

const sqlite = require("sqlite3");
const logger = require("./logger");
const config = require("../config");
const { TimeTracker } = require("./time");
const dataFolder = "data/";
class Database {
constructor(app) {
this._app = app;
this._sql = new sqlite.Database(dataFolder + "database.sql");
}
getDailyDatabase() {
if (!config.createDailyDatabaseCopy) {
return;
}
const date = new Date();
const fileName = `database_copy_${date.getDate()}-${
date.getMonth() + 1
}-${date.getFullYear()}.sql`;
if (fileName !== this._currentDatabaseCopyFileName) {
if (this._currentDatabaseCopyInstance) {
this._currentDatabaseCopyInstance.close();
}
this._currentDatabaseCopyInstance = new sqlite.Database(
dataFolder + fileName
);
this._currentDatabaseCopyFileName = fileName;
// Ensure the initial tables are created
// This does not created indexes since it is only inserted to
this._currentDatabaseCopyInstance.serialize(() => {
this._currentDatabaseCopyInstance.run(
"CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)",
(err) => {
if (err) {
logger.log(
"error",
"Cannot create initial table for daily database"
);
throw err;
}
}
);
});
}
return this._currentDatabaseCopyInstance;
}
ensureIndexes(callback) {
const handleError = (err) => {
if (err) {
logger.log("error", "Cannot create table or table index");
throw err;
}
};
this._sql.serialize(() => {
this._sql.run(
"CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)",
handleError
);
this._sql.run(
"CREATE TABLE IF NOT EXISTS players_record (timestamp BIGINT, ip TINYTEXT NOT NULL PRIMARY KEY, playerCount MEDIUMINT)",
handleError
);
this._sql.run(
"CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)",
handleError
);
this._sql.run(
"CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)",
[],
(err) => {
handleError(err);
// Queries are executed one at a time; this is the last one.
// Note that queries not scheduled directly in the callback function of
// #serialize are not necessarily serialized.
callback();
}
);
});
}
loadGraphPoints(graphDuration, callback) {
// Query recent pings
const endTime = TimeTracker.getEpochMillis();
const startTime = endTime - graphDuration;
this.getRecentPings(startTime, endTime, (pingData) => {
const relativeGraphData = [];
for (const row of pingData) {
// Load into temporary array
// This will be culled prior to being pushed to the serverRegistration
let graphData = relativeGraphData[row.ip];
if (!graphData) {
relativeGraphData[row.ip] = graphData = [[], []];
}
// DANGER!
// This will pull the timestamp from each row into memory
// This is built under the assumption that each round of pings shares the same timestamp
// This enables all timestamp arrays to have consistent point selection and graph correctly
graphData[0].push(row.timestamp);
graphData[1].push(row.playerCount);
}
Object.keys(relativeGraphData).forEach((ip) => {
// Match IPs to serverRegistration object
for (const serverRegistration of this._app.serverRegistrations) {
if (serverRegistration.data.ip === ip) {
const graphData = relativeGraphData[ip];
// Push the data into the instance and cull if needed
serverRegistration.loadGraphPoints(
startTime,
graphData[0],
graphData[1]
);
break;
}
}
});
// Since all timestamps are shared, use the array from the first ServerRegistration
// This is very dangerous and can break if data is out of sync
if (Object.keys(relativeGraphData).length > 0) {
const serverIp = Object.keys(relativeGraphData)[0];
const timestamps = relativeGraphData[serverIp][0];
this._app.timeTracker.loadGraphPoints(startTime, timestamps);
}
callback();
});
}
loadRecords(callback) {
let completedTasks = 0;
this._app.serverRegistrations.forEach((serverRegistration) => {
// Find graphPeaks
// This pre-computes the values prior to clients connecting
serverRegistration.findNewGraphPeak();
// Query recordData
// When complete increment completeTasks to know when complete
this.getRecord(
serverRegistration.data.ip,
(hasRecord, playerCount, timestamp) => {
if (hasRecord) {
serverRegistration.recordData = {
playerCount,
timestamp: TimeTracker.toSeconds(timestamp),
};
} else {
this.getRecordLegacy(
serverRegistration.data.ip,
(hasRecordLegacy, playerCountLegacy, timestampLegacy) => {
// New values that will be inserted to table
let newTimestamp = null;
let newPlayerCount = null;
// If legacy record found, use it for insertion
if (hasRecordLegacy) {
newTimestamp = timestampLegacy;
newPlayerCount = playerCountLegacy;
}
// Set record to recordData
serverRegistration.recordData = {
playerCount: newPlayerCount,
timestamp: TimeTracker.toSeconds(newTimestamp),
};
// Insert server entry to records table
const statement = this._sql.prepare(
"INSERT INTO players_record (timestamp, ip, playerCount) VALUES (?, ?, ?)"
);
statement.run(
newTimestamp,
serverRegistration.data.ip,
newPlayerCount,
(err) => {
if (err) {
logger.error(
`Cannot insert initial player count record of ${serverRegistration.data.ip}`
);
throw err;
}
}
);
statement.finalize();
}
);
}
// Check if completedTasks hit the finish value
// Fire callback since #readyDatabase is complete
if (++completedTasks === this._app.serverRegistrations.length) {
callback();
}
}
);
});
}
getRecentPings(startTime, endTime, callback) {
this._sql.all(
"SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?",
[startTime, endTime],
(err, data) => {
if (err) {
logger.log("error", "Cannot get recent pings");
throw err;
}
callback(data);
}
);
}
getRecord(ip, callback) {
this._sql.all(
"SELECT playerCount, timestamp FROM players_record WHERE ip = ?",
[ip],
(err, data) => {
if (err) {
logger.log("error", `Cannot get ping record for ${ip}`);
throw err;
}
// Record not found
if (data[0] === undefined) {
callback(false);
return;
}
const playerCount = data[0].playerCount;
const timestamp = data[0].timestamp;
// Allow null player counts and timestamps, the frontend will safely handle them
callback(true, playerCount, timestamp);
}
);
}
// Retrieves record from pings table, used for converting to separate table
getRecordLegacy(ip, callback) {
this._sql.all(
"SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?",
[ip],
(err, data) => {
if (err) {
logger.log("error", `Cannot get legacy ping record for ${ip}`);
throw err;
}
// For empty results, data will be length 1 with [null, null]
const playerCount = data[0]["MAX(playerCount)"];
const timestamp = data[0].timestamp;
// Allow null timestamps, the frontend will safely handle them
// This allows insertion of free standing records without a known timestamp
if (playerCount !== null) {
callback(true, playerCount, timestamp);
} else {
callback(false);
}
}
);
}
insertPing(ip, timestamp, unsafePlayerCount) {
this._insertPingTo(ip, timestamp, unsafePlayerCount, this._sql);
// Push a copy of the data into the database copy, if any
// This creates an "insert only" copy of the database for archiving
const dailyDatabase = this.getDailyDatabase();
if (dailyDatabase) {
this._insertPingTo(ip, timestamp, unsafePlayerCount, dailyDatabase);
}
}
_insertPingTo(ip, timestamp, unsafePlayerCount, db) {
const statement = db.prepare(
"INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)"
);
statement.run(timestamp, ip, unsafePlayerCount, (err) => {
if (err) {
logger.error(`Cannot insert ping record of ${ip} at ${timestamp}`);
throw err;
}
});
statement.finalize();
}
updatePlayerCountRecord(ip, playerCount, timestamp) {
const statement = this._sql.prepare(
"UPDATE players_record SET timestamp = ?, playerCount = ? WHERE ip = ?"
);
statement.run(timestamp, playerCount, ip, (err) => {
if (err) {
logger.error(
`Cannot update player count record of ${ip} at ${timestamp}`
);
throw err;
}
});
statement.finalize();
}
initOldPingsDelete(callback) {
// Delete old pings on startup
logger.info("Deleting old pings..");
this.deleteOldPings(() => {
const oldPingsCleanupInterval =
config.oldPingsCleanup.interval || 3600000;
if (oldPingsCleanupInterval > 0) {
// Delete old pings periodically
setInterval(() => this.deleteOldPings(), oldPingsCleanupInterval);
}
callback();
});
}
deleteOldPings(callback) {
// The oldest timestamp that will be kept
const oldestTimestamp = TimeTracker.getEpochMillis() - config.graphDuration;
const deleteStart = TimeTracker.getEpochMillis();
const statement = this._sql.prepare(
"DELETE FROM pings WHERE timestamp < ?;"
);
statement.run(oldestTimestamp, (err) => {
if (err) {
logger.error("Cannot delete old pings");
throw err;
} else {
const deleteTook = TimeTracker.getEpochMillis() - deleteStart;
logger.info(`Old pings deleted in ${deleteTook}ms`);
if (callback) {
callback();
}
}
});
statement.finalize();
}
}
module.exports = Database;