Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Refresh.Common/RefreshContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ public enum RefreshContext
Database,
CacheService,
RateLimit,
UserMetrics,
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe
}

dataContext.Match.RoomAccessor.UpdateRoom(room);
dataContext.Database.UpdatePlayTimeOnUserMetric(dataContext.User!, dataContext.Game!, dataContext.Platform!);

return OK;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe
dataContext.Match.AddPlayerToRoom(playerUsername, room);
}

dataContext.Database.UpdatePlayTimeOnUserMetric(dataContext.User!, dataContext.Game!, dataContext.Platform!);
return OK;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public Response Execute(DataContext dataContext, SerializedRoomData body, GameSe
}

dataContext.Match.RoomAccessor.UpdateRoom(room);
dataContext.Database.UpdatePlayTimeOnUserMetric(dataContext.User!, dataContext.Game!, dataContext.Platform!);

return OK;
}
Expand Down
98 changes: 98 additions & 0 deletions Refresh.Database/GameDatabaseContext.Metrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Refresh.Common;
using Refresh.Database.Models.Authentication;
using Refresh.Database.Models.Metrics;
using Refresh.Database.Models.Users;

namespace Refresh.Database;

public partial class GameDatabaseContext // Metrics
{
private IQueryable<UserGameMetric> UserGameMetricsIncluded => this.UserGameMetrics
.Include(m => m.User);

public UserGameMetric GetGameMetricForUser(GameUser user, TokenGame game, TokenPlatform platform)
{
UserGameMetric? metric = this.UserGameMetricsIncluded
.FirstOrDefault(m => m.UserId == user.UserId && m.Game == game && m.Platform == platform);

// If it doesn't exist yet, create it. Timestamps will be properly set by their dedicated methods when needed.
if (metric == null)
{
this._logger.LogDebug(RefreshContext.UserMetrics, $"Creating new game metric for {user} for {game}/{platform}.");
metric = new()
{
UserId = user.UserId,
User = user,
Game = game,
Platform = platform,
LastLoginAt = DateTimeOffset.MinValue,
LastRoomUpdateAt = DateTimeOffset.MinValue,
TotalPlayTimeMinutes = 0,
};
this.UserGameMetrics.Add(metric);

this.TrackUserAsUnchanged(user);
this.SaveChanges();
}

return metric;
}

/// <summary>
/// Gets the user's playtime across all games and platforms.
/// </summary>
public long GetTotalPlayTimeByUser(GameUser user)
{
return this.UserGameMetrics
.Where(m => m.UserId == user.UserId)
.Sum(m => m.TotalPlayTimeMinutes);
}

public UserGameMetric UpdateLoginDateOnUserMetric(GameUser user, TokenGame game, TokenPlatform platform)
{
UserGameMetric metric = this.GetGameMetricForUser(user, game, platform);
DateTimeOffset now = this._time.Now;

this._logger.LogDebug(RefreshContext.UserMetrics, $"Updating {user}'s last login date for {game}/{platform}: from {metric.LastLoginAt} to {now}.");
this.UserGameMetrics.Update(metric);
metric.LastLoginAt = now;

this.SaveChanges();
return metric;
}

public UserGameMetric UpdatePlayTimeOnUserMetric(GameUser user, TokenGame game, TokenPlatform platform)
{
UserGameMetric metric = this.GetGameMetricForUser(user, game, platform);
DateTimeOffset now = this._time.Now;
this._logger.LogDebug(RefreshContext.UserMetrics, $"Checking whether to update {user}'s playtime for game {game}/platform {platform}: last login at {metric.LastLoginAt}, last room update at {metric.LastRoomUpdateAt}.");

// Don't count the time between login and first /match, only count the time between /match requests,
// which we would consider to be actual playtime. Also, explicitly don't increment time if this metric
// hasn't been initialized yet. In that case LastLoginAt may also be at MinValue, but it might be better to just
// check for it explicitly anyway.
if (metric.LastRoomUpdateAt > metric.LastLoginAt && metric.LastRoomUpdateAt > DateTimeOffset.MinValue)
{
long additionalMinutes = (now.ToUnixTimeSeconds() - metric.LastRoomUpdateAt.ToUnixTimeSeconds()) / 60;
this._logger.LogWarning(RefreshContext.UserMetrics, $"Updating {user}'s playtime for game {game}/platform {platform}: {metric.TotalPlayTimeMinutes} min + {additionalMinutes} min");

this.WriteEnsuringStatistics(user, () =>
{
this.UserGameMetrics.Update(metric);
metric.LastRoomUpdateAt = now;
metric.TotalPlayTimeMinutes += additionalMinutes;

user.Statistics!.TotalPlayTimeMinutes += additionalMinutes;
});
}
else
{
this.UserGameMetrics.Update(metric);
// Always update LastRoomUpdateAt, since we will use it to determine whether we should update this metric the next time (see above).
metric.LastRoomUpdateAt = now;
this.SaveChanges();
}

return metric;
}
}
4 changes: 3 additions & 1 deletion Refresh.Database/GameDatabaseContext.Statistics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ public void RecalculateScoreStatistics(GameLevel level, bool saveChanges = true)

#region Users

internal const int UserStatisticsVersion = 2;
internal const int UserStatisticsVersion = 3;

public IEnumerable<GameUser> GetUsersWithStatisticsNeedingUpdates()
{
Expand Down Expand Up @@ -326,6 +326,7 @@ private void WriteEnsuringStatistics(GameUser user, Action action)
this.CalculateUserStatisticsIfNotPresent(user);
action();
this.MarkUserStatisticsDirty(user);
this.GameUserStatistics.Update(user.Statistics!);
});
}

Expand Down Expand Up @@ -353,6 +354,7 @@ private void RecalculateUserStatisticsInternal(GameUser user)
user.Statistics.FavouriteLevelCount = this.GetTotalLevelsFavouritedByUser(user);
user.Statistics.FavouritePlaylistCount = this.GetTotalPlaylistsFavouritedByUser(user);
user.Statistics.PlaylistCount = this.GetTotalPlaylistsByAuthor(user);
user.Statistics.TotalPlayTimeMinutes = this.GetTotalPlayTimeByUser(user);

user.Statistics.RecalculateAt = null;
user.Statistics.Version = UserStatisticsVersion;
Expand Down
13 changes: 13 additions & 0 deletions Refresh.Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ public partial class GameDatabaseContext // Users
private IQueryable<PreviousUsername> PreviousUsernamesIncluded => this.PreviousUsernames
.Include(u => u.User)
.Include(u => u.User.Statistics);

// TODO use this method in more DB methods where an entity referencing a user is updated/inserted without updating the user or any of the user's referenced entities,
// else there will be a high change that the user isn't actually tracked by Postgres yet, making it insert the user (and fail for obvious reasons).
// TODO also use such methods for levels and other commonly referenced entities for even more deduplication.
// public so tests could access this manually if necessary
public void TrackUserAsUnchanged(GameUser user)
{
this.GameUsers.Attach(user);
if (user.Statistics != null)
{
this.GameUserStatistics.Attach(user.Statistics);
}
}

[Pure]
[ContractAnnotation("username:null => null; username:notnull => canbenull")]
Expand Down
2 changes: 2 additions & 0 deletions Refresh.Database/GameDatabaseContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
using Refresh.Database.Models.Workers;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
using Refresh.Database.Models.Moderation;
using Refresh.Database.Models.Metrics;

namespace Refresh.Database;

Expand Down Expand Up @@ -87,6 +88,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext
internal DbSet<GameLevelRevision> GameLevelRevisions { get; set; }
internal DbSet<ModerationAction> ModerationActions { get; set; }
internal DbSet<EntityUploadRateLimit> EntityUploadRateLimits { get; set; }
internal DbSet<UserGameMetric> UserGameMetrics { get; set; }

#pragma warning disable CS8618 // Non-nullable variable must contain a non-null value when exiting constructor. Consider declaring it as nullable.
internal GameDatabaseContext(Logger logger, IDateTimeProvider time, IDatabaseConfig dbConfig)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Refresh.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(GameDatabaseContext))]
[Migration("20261008091428_AddUserGameMetricsTable")]
public partial class AddUserGameMetricsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "TotalPlayTimeMinutes",
table: "GameUserStatistics",
type: "bigint",
nullable: false,
defaultValue: 0L);

migrationBuilder.CreateTable(
name: "UserGameMetrics",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
Game = table.Column<int>(type: "integer", nullable: false),
Platform = table.Column<int>(type: "integer", nullable: false),
LastLoginAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastRoomUpdateAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
TotalPlayTimeMinutes = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserGameMetrics", x => new { x.UserId, x.Game, x.Platform });
table.ForeignKey(
name: "FK_UserGameMetrics_GameUsers_UserId",
column: x => x.UserId,
principalTable: "GameUsers",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
});
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserGameMetrics");

migrationBuilder.DropColumn(
name: "TotalPlayTimeMinutes",
table: "GameUserStatistics");
}
}
}
39 changes: 39 additions & 0 deletions Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,31 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("GameScores");
});

modelBuilder.Entity("Refresh.Database.Models.Metrics.UserGameMetric", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");

b.Property<int>("Game")
.HasColumnType("integer");

b.Property<int>("Platform")
.HasColumnType("integer");

b.Property<DateTimeOffset>("LastLoginAt")
.HasColumnType("timestamp with time zone");

b.Property<DateTimeOffset>("LastRoomUpdateAt")
.HasColumnType("timestamp with time zone");

b.Property<long>("TotalPlayTimeMinutes")
.HasColumnType("bigint");

b.HasKey("UserId", "Game", "Platform");

b.ToTable("UserGameMetrics");
});

modelBuilder.Entity("Refresh.Database.Models.Moderation.ModerationAction", b =>
{
b.Property<string>("ActionId")
Expand Down Expand Up @@ -1503,6 +1528,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<int>("ReviewCount")
.HasColumnType("integer");

b.Property<long>("TotalPlayTimeMinutes")
.HasColumnType("bigint");

b.Property<int>("Version")
.HasColumnType("integer");

Expand Down Expand Up @@ -2074,6 +2102,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Publisher");
});

modelBuilder.Entity("Refresh.Database.Models.Metrics.UserGameMetric", b =>
{
b.HasOne("Refresh.Database.Models.Users.GameUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();

b.Navigation("User");
});

modelBuilder.Entity("Refresh.Database.Models.Moderation.ModerationAction", b =>
{
b.HasOne("Refresh.Database.Models.Users.GameUser", "Actor")
Expand Down
27 changes: 27 additions & 0 deletions Refresh.Database/Models/Metrics/UserGameMetric.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using MongoDB.Bson;
using Refresh.Database.Models.Authentication;
using Refresh.Database.Models.Levels;
using Refresh.Database.Models.Users;

namespace Refresh.Database.Models.Metrics;

[PrimaryKey(nameof(UserId), nameof(Game), nameof(Platform))]
public class UserGameMetric
{
[Required]
public ObjectId UserId { get; set; }

[Required, ForeignKey(nameof(UserId))]
public GameUser User { get; set; } = null!;

public TokenGame Game { get; set; }
public TokenPlatform Platform { get; set; }

public DateTimeOffset LastLoginAt { get; set; }
public DateTimeOffset LastRoomUpdateAt { get; set; }

/// <summary>
/// Total time the user has spent on this game, determined by the times inbetween their room update requests.
/// </summary>
public long TotalPlayTimeMinutes { get; set; }
}
1 change: 1 addition & 0 deletions Refresh.Database/Models/Statistics/GameUserStatistics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ public class GameUserStatistics
public int FavouritePlaylistCount { get; set; }
public int QueueCount { get; set; }
public int PlaylistCount { get; set; }
public long TotalPlayTimeMinutes { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,11 @@ public class AuthenticationEndpoints : EndpointGroup
// !!

Token token = database.GenerateTokenForUser(user, TokenType.Game, game.Value, platform.Value, ipAddress, GameDatabaseContext.GameTokenExpirySeconds); // 4 hours


// Let the metrics know that this user has started a new session, so the time between login and first room update
// should be ignored.
database.UpdateLoginDateOnUserMetric(user, game.Value, platform.Value);

// Clear the user's force match
database.ClearForceMatch(user);

Expand Down
Loading
Loading