Remove per-guild schema

All guild data will now be stored within the same table, instead of
a new schema being created for each guild. This avoids issues with
missing schemas.
This likely wasn't a good idea to begin with.
This commit is contained in:
Noikoio 2018-12-18 16:21:35 -08:00
parent de8660d913
commit 47a738ddbc
5 changed files with 111 additions and 139 deletions

View file

@ -69,12 +69,9 @@ namespace Kerobot
/// <param name="guild"> /// <param name="guild">
/// If manipulating guild-specific information, this parameter sets the database connection's search path. /// If manipulating guild-specific information, this parameter sets the database connection's search path.
/// </param> /// </param>
internal async Task<NpgsqlConnection> GetOpenNpgsqlConnectionAsync(ulong? guild) internal async Task<NpgsqlConnection> GetOpenNpgsqlConnectionAsync()
{ {
string cs = Config.PostgresConnString; var db = new NpgsqlConnection(Config.PostgresConnString);
if (guild.HasValue) cs += ";searchpath=guild_" + guild.Value;
var db = new NpgsqlConnection(cs);
await db.OpenAsync(); await db.OpenAsync();
return db; return db;
} }

View file

@ -59,7 +59,8 @@ namespace Kerobot
var client = new DiscordSocketClient(new DiscordSocketConfig() var client = new DiscordSocketClient(new DiscordSocketConfig()
{ {
DefaultRetryMode = RetryMode.AlwaysRetry, DefaultRetryMode = RetryMode.AlwaysRetry,
MessageCacheSize = 0 // using our own MessageCacheSize = 0, // using our own
LogLevel = LogSeverity.Info
}); });
// Kerobot class initialization - will set up services and modules // Kerobot class initialization - will set up services and modules

View file

@ -1,10 +1,9 @@
using Discord.WebSocket; using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Npgsql;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Discord.WebSocket;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Kerobot.Services.GuildState namespace Kerobot.Services.GuildState
{ {
@ -14,8 +13,6 @@ namespace Kerobot.Services.GuildState
/// </summary> /// </summary>
class GuildStateService : Service class GuildStateService : Service
{ {
// SCHEMAS ARE CREATED HERE, SOMEWHERE BELOW. MAKE SURE THIS CONSTRUCTOR IS CALLED EARLY.
private readonly object _storageLock = new object(); private readonly object _storageLock = new object();
private readonly Dictionary<ulong, Dictionary<Type, StateInfo>> _storage; private readonly Dictionary<ulong, Dictionary<Type, StateInfo>> _storage;
@ -24,8 +21,7 @@ namespace Kerobot.Services.GuildState
public GuildStateService(Kerobot kb) : base(kb) public GuildStateService(Kerobot kb) : base(kb)
{ {
_storage = new Dictionary<ulong, Dictionary<Type, StateInfo>>(); _storage = new Dictionary<ulong, Dictionary<Type, StateInfo>>();
CreateDatabaseTablesAsync().Wait();
_defaultGuildJson = PreloadDefaultGuildJson();
kb.DiscordClient.GuildAvailable += DiscordClient_GuildAvailable; kb.DiscordClient.GuildAvailable += DiscordClient_GuildAvailable;
kb.DiscordClient.JoinedGuild += DiscordClient_JoinedGuild; kb.DiscordClient.JoinedGuild += DiscordClient_JoinedGuild;
@ -48,31 +44,11 @@ namespace Kerobot.Services.GuildState
} }
/// <summary> /// <summary>
/// Initializes guild in-memory and database structures, then attempts to load configuration. /// Initializes guild in-memory structures and attempts to load configuration.
/// </summary> /// </summary>
private async Task InitializeGuild(SocketGuild arg) private async Task InitializeGuild(SocketGuild arg)
{ {
// Get this done before any other thing. // We're only loading config here now.
await CreateSchema(arg.Id);
// Attempt initialization on the guild. All services will set up their tables here.
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(arg.Id))
{
foreach (var svc in Kerobot.Services)
{
try
{
await svc.CreateDatabaseTablesAsync(db);
}
catch (NpgsqlException ex)
{
await Log("Database error on CreateDatabaseTablesAsync:\n"
+ $"-- Service: {svc.Name}\n-- Guild: {arg.Id}\n-- Error: {ex.Message}", true);
}
}
}
// Then start loading guild information
bool success = await LoadGuildConfiguration(arg.Id); bool success = await LoadGuildConfiguration(arg.Id);
if (!success) if (!success)
{ {
@ -118,6 +94,7 @@ namespace Kerobot.Services.GuildState
{ {
var jstr = await RetrieveConfiguration(guildId); var jstr = await RetrieveConfiguration(guildId);
if (jstr == null) jstr = await RetrieveDefaultConfiguration();
int jstrHash = jstr.GetHashCode(); int jstrHash = jstr.GetHashCode();
JObject guildConf; JObject guildConf;
try try
@ -180,59 +157,47 @@ namespace Kerobot.Services.GuildState
} }
#region Database #region Database
/// <summary>
/// Creates a schema for holding all guild data.
/// Ensure that this runs first before any other database call to a guild.
/// </summary>
private async Task CreateSchema(ulong guildId)
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(null))
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create schema if not exists guild_{guildId}";
await c.ExecuteNonQueryAsync();
}
}
}
const string DBTableName = "guild_configuration"; const string DBTableName = "guild_configuration";
/// <summary> /// <summary>
/// Creates the table structures for holding module configuration. /// Creates the table structures for holding guild configuration.
/// </summary> /// </summary>
public override async Task CreateDatabaseTablesAsync(NpgsqlConnection db) private async Task CreateDatabaseTablesAsync()
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
{ {
using (var c = db.CreateCommand()) using (var c = db.CreateCommand())
{ {
c.CommandText = $"create table if not exists {DBTableName} (" c.CommandText = $"create table if not exists {DBTableName} ("
+ $"rev_id SERIAL primary key, " + $"rev_id SERIAL primary key, "
+ "guild_id bigint not null, "
+ "author bigint not null, " + "author bigint not null, "
+ "rev_date timestamptz not null default NOW(), " + "rev_date timestamptz not null default NOW(), "
+ "config_json text not null" + "config_json text not null"
+ ")"; + ")";
await c.ExecuteNonQueryAsync(); await c.ExecuteNonQueryAsync();
} }
// Creating default configuration with revision ID 0. // Creating default configuration with revision ID 0.
// This allows us to quickly define rev_id as type SERIAL and not have to configure it so that // Config ID 0 is used when no other configurations can be loaded for a guild.
// the serial should start at 2, but rather can easily start at 1. So lazy.
using (var c = db.CreateCommand()) using (var c = db.CreateCommand())
{ {
c.CommandText = $"insert into {DBTableName} (rev_id, author, config_json)" c.CommandText = $"insert into {DBTableName} (rev_id, guild_id, author, config_json) "
+ "values (0, 0, @Json) " + "values (0, 0, 0, @Json) "
+ "on conflict (rev_id) do nothing"; + "on conflict (rev_id) do nothing";
c.Parameters.Add("@Json", NpgsqlTypes.NpgsqlDbType.Text).Value = _defaultGuildJson; c.Parameters.Add("@Json", NpgsqlTypes.NpgsqlDbType.Text).Value = PreloadDefaultGuildJson();
c.Prepare(); c.Prepare();
await c.ExecuteNonQueryAsync(); await c.ExecuteNonQueryAsync();
} }
} }
}
private async Task<string> RetrieveConfiguration(ulong guildId) private async Task<string> RetrieveConfiguration(ulong guildId)
{ {
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(guildId)) using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
{ {
using (var c = db.CreateCommand()) using (var c = db.CreateCommand())
{ {
c.CommandText = $"select config_json from {DBTableName} " c.CommandText = $"select config_json from {DBTableName} where guild_id = {guildId} "
+ "order by rev_id desc limit 1"; + "order by rev_id desc limit 1";
using (var r = await c.ExecuteReaderAsync()) using (var r = await c.ExecuteReaderAsync())
{ {
@ -245,10 +210,25 @@ namespace Kerobot.Services.GuildState
} }
} }
} }
private async Task<string> RetrieveDefaultConfiguration()
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"select config_json from {DBTableName} where rev_id = 0";
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync()) return r.GetString(0);
}
}
}
throw new Exception("Unable to retrieve fallback configuration.");
}
#endregion #endregion
// Default guild configuration JSON is embedded in assembly. Retrieving and caching it here. // Default guild configuration JSON is embedded in assembly.
private readonly string _defaultGuildJson;
private string PreloadDefaultGuildJson() private string PreloadDefaultGuildJson()
{ {
const string ResourceName = "Kerobot.DefaultGuildConfig.json"; const string ResourceName = "Kerobot.DefaultGuildConfig.json";

View file

@ -1,13 +1,15 @@
using Discord; using System;
using Npgsql;
using NpgsqlTypes;
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Discord;
using NpgsqlTypes;
namespace Kerobot.Services.Logging namespace Kerobot.Services.Logging
{ {
/// <summary> /// <summary>
/// Implements logging for the whole program. /// Implements logging. Logging is distinguished into two types: Instance and per-guild.
/// Instance logs are messages of varying importance to the bot operator. Guild logs are messages that can be seen
/// by moderators of a particular guild. All log messages are backed by database.
/// Instance logs are stored as guild ID 0.
/// </summary> /// </summary>
class LoggingService : Service class LoggingService : Service
{ {
@ -16,13 +18,8 @@ namespace Kerobot.Services.Logging
internal LoggingService(Kerobot kb) : base(kb) internal LoggingService(Kerobot kb) : base(kb)
{ {
// Create global instance log table // Create logging table
async Task CreateGlobalTable() CreateDatabaseTablesAsync().Wait();
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(null))
await CreateDatabaseTablesAsync(db);
}
CreateGlobalTable().Wait();
// Discord.Net log handling (client logging option is specified in Program.cs) // Discord.Net log handling (client logging option is specified in Program.cs)
kb.DiscordClient.Log += DiscordClient_Log; kb.DiscordClient.Log += DiscordClient_Log;
@ -34,7 +31,7 @@ namespace Kerobot.Services.Logging
/// <summary> /// <summary>
/// Discord.Net logging events handled here. /// Discord.Net logging events handled here.
/// Only events with high severity are placed in the log. Others are just printed to console. /// Only events with high importance are kept. Others are just printed to console.
/// </summary> /// </summary>
private async Task DiscordClient_Log(LogMessage arg) private async Task DiscordClient_Log(LogMessage arg)
{ {
@ -43,37 +40,45 @@ namespace Kerobot.Services.Logging
string msg = $"[{Enum.GetName(typeof(LogSeverity), arg.Severity)}] {arg.Message}"; string msg = $"[{Enum.GetName(typeof(LogSeverity), arg.Severity)}] {arg.Message}";
const string logSource = "Discord.Net"; const string logSource = "Discord.Net";
if (important) if (important) await DoInstanceLogAsync(true, logSource, msg);
{ else FormatToConsole(DateTimeOffset.UtcNow, logSource, msg);
// Note: Using external method here!
await Kerobot.InstanceLogAsync(true, logSource, msg);
}
else
{
FormatToConsole(DateTimeOffset.UtcNow, logSource, msg);
}
} }
#region Database
const string TableLog = "program_log"; const string TableLog = "program_log";
public override async Task CreateDatabaseTablesAsync(NpgsqlConnection db) private async Task CreateDatabaseTablesAsync()
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
{ {
using (var c = db.CreateCommand()) using (var c = db.CreateCommand())
{ {
c.CommandText = $"create table if not exists {TableLog} (" c.CommandText = $"create table if not exists {TableLog} ("
+ "log_id serial primary key, " + "log_id serial primary key, "
+ "guild_id bigint not null, "
+ "log_timestamp timestamptz not null, " + "log_timestamp timestamptz not null, "
+ "log_source text not null, " + "log_source text not null, "
+ "message text not null" + "message text not null"
+ ")"; + ")";
await c.ExecuteNonQueryAsync(); await c.ExecuteNonQueryAsync();
} }
using (var c = db.CreateCommand())
{
c.CommandText = "create index if not exists " +
$"{TableLog}_guildid_idx on {TableLog} guild_id";
await c.ExecuteNonQueryAsync();
} }
private async Task TableInsertAsync(NpgsqlConnection db, DateTimeOffset timestamp, string source, string message) }
}
private async Task TableInsertAsync(ulong guildId, DateTimeOffset timestamp, string source, string message)
{
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync())
{ {
using (var c = db.CreateCommand()) using (var c = db.CreateCommand())
{ {
c.CommandText = $"insert into {TableLog} (log_timestamp, log_source, message) values" c.CommandText = $"insert into {TableLog} (guild_id, log_timestamp, log_source, message) values"
+ "(@Ts, @Src, @Msg)"; + "(@Gid, @Ts, @Src, @Msg)";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guildId;
c.Parameters.Add("@Ts", NpgsqlDbType.TimestampTZ).Value = timestamp; c.Parameters.Add("@Ts", NpgsqlDbType.TimestampTZ).Value = timestamp;
c.Parameters.Add("@Src", NpgsqlDbType.Text).Value = source; c.Parameters.Add("@Src", NpgsqlDbType.Text).Value = source;
c.Parameters.Add("@Msg", NpgsqlDbType.Text).Value = message; c.Parameters.Add("@Msg", NpgsqlDbType.Text).Value = message;
@ -81,6 +86,8 @@ namespace Kerobot.Services.Logging
await c.ExecuteNonQueryAsync(); await c.ExecuteNonQueryAsync();
} }
} }
}
#endregion
/// <summary> /// <summary>
/// All console writes originate here. /// All console writes originate here.
@ -106,17 +113,15 @@ namespace Kerobot.Services.Logging
Exception insertException = null; Exception insertException = null;
try try
{ {
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(null)) await TableInsertAsync(0, DateTimeOffset.UtcNow, source, message);
{
await TableInsertAsync(db, DateTimeOffset.UtcNow, source, message);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
// This is not good. Resorting to plain console write to report the issue. // Not good. Resorting to plain console write to report the error.
// Let's hope a warning reaches the reporting channel.
Console.WriteLine("!!! Error during recording to instance log: " + ex.Message); Console.WriteLine("!!! Error during recording to instance log: " + ex.Message);
Console.WriteLine(ex.StackTrace); Console.WriteLine(ex.StackTrace);
// Attempt to pass this error to the reporting channel.
insertException = ex; insertException = ex;
} }
@ -175,14 +180,11 @@ namespace Kerobot.Services.Logging
{ {
try try
{ {
using (var db = await Kerobot.GetOpenNpgsqlConnectionAsync(guild)) await TableInsertAsync(guild, DateTimeOffset.UtcNow, source, message);
{
await TableInsertAsync(db, DateTimeOffset.UtcNow, source, message);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
// Probably a bad idea, but... // This is probably a terrible idea, but...
await DoInstanceLogAsync(true, this.Name, "Failed to store guild log item: " + ex.Message); await DoInstanceLogAsync(true, this.Name, "Failed to store guild log item: " + ex.Message);
// Stack trace goes to console only. // Stack trace goes to console only.
FormatToConsole(DateTime.UtcNow, this.Name, ex.StackTrace); FormatToConsole(DateTime.UtcNow, this.Name, ex.StackTrace);

View file

@ -17,14 +17,6 @@ namespace Kerobot.Services
public Service(Kerobot kb) => Kerobot = kb; public Service(Kerobot kb) => Kerobot = kb;
/// <summary>
/// Initializes database tables per-guild.
/// This method is called by GuildStateService when entering a guild.
/// </summary>
/// <param name="db">An opened database connection with the appropriate schema option set.</param>
/// <remarks>If overriding, calling the base method is not necessary.</remarks>
public virtual Task CreateDatabaseTablesAsync(Npgsql.NpgsqlConnection db) => Task.CompletedTask;
/// <summary> /// <summary>
/// Creates a log message. /// Creates a log message.
/// </summary> /// </summary>