Skip to content

Commit b81c730

Browse files
committed
Fix player persistence race conditions with per-player async locking
- Introduce per-player AsyncLock in AislingStorage to serialize all player writes - Remove shared ServerSaveConnection usage from save and delete paths - Make player saves awaitable and transactional - Serialize item deletes, bank mass deletes, and exchanges with save pipeline - Split Save into lock-owning and core methods to avoid re-entrant deadlocks - Fix rare inventory corruption during logout, exchange, and auto-save
1 parent 0b75ab2 commit b81c730

File tree

8 files changed

+128
-170
lines changed

8 files changed

+128
-170
lines changed

Zolian.Server.Base/Database/AislingStorage.cs

Lines changed: 87 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
using Dapper;
2-
3-
using Darkages.Enums;
4-
using Darkages.Sprites;
5-
using Microsoft.Data.SqlClient;
1+
using System.Collections.Concurrent;
62
using System.Data;
73
using System.Numerics;
4+
5+
using Dapper;
6+
7+
using Darkages.Common;
8+
using Darkages.Enums;
89
using Darkages.Models;
10+
using Darkages.Network.Server;
11+
using Darkages.Sprites;
912
using Darkages.Templates;
13+
14+
using Microsoft.Data.SqlClient;
1015
using Microsoft.Extensions.Logging;
11-
using Darkages.Network.Server;
12-
using Darkages.Common;
1316

1417
namespace Darkages.Database;
1518

@@ -18,9 +21,11 @@ public record AislingStorage : Sql, IEqualityOperators<AislingStorage, AislingSt
1821
public const string ConnectionString = "Data Source=.;Initial Catalog=ZolianPlayers;Integrated Security=True;Encrypt=False;MultipleActiveResultSets=True;";
1922
public const string PersonalMailString = "Data Source=.;Initial Catalog=ZolianBoardsMail;Integrated Security=True;Encrypt=False;MultipleActiveResultSets=True;";
2023
private const string EncryptedConnectionString = "Data Source=.;Initial Catalog=ZolianPlayers;Integrated Security=True;Column Encryption Setting=enabled;TrustServerCertificate=True;MultipleActiveResultSets=True;";
21-
public AsyncLock SaveLock { get; } = new();
2224
private AsyncLock LoadLock { get; } = new();
2325
private AsyncLock CreateLock { get; } = new();
26+
private readonly ConcurrentDictionary<uint, AsyncLock> _playerLocks = new();
27+
public AsyncLock GetPlayerLock(uint serial) => _playerLocks.GetOrAdd(serial, static _ => new AsyncLock());
28+
public void TryRemovePlayerLock(uint serial) => _playerLocks.TryRemove(serial, out _);
2429

2530
#region LoginServer Operations
2631

@@ -309,175 +314,116 @@ public static async Task<bool> PasswordSaveAttempt(Aisling obj)
309314
/// Saves a player's state on disconnect or error
310315
/// Utilizes an active connection that self-heals if closed
311316
/// </summary>
312-
public static async Task<bool> Save(Aisling obj)
317+
public async Task<bool> Save(Aisling obj, CancellationToken ct = default)
313318
{
314319
if (obj == null) return false;
315320
if (obj.Loading) return false;
316-
var connection = ServerSetup.Instance.ServerSaveConnection;
317321

318-
try
322+
using (await GetPlayerLock(obj.Serial).LockAsync().ConfigureAwait(false))
319323
{
320-
_ = PlayerSaveRoutine(obj, connection);
321-
}
322-
catch (Exception e)
323-
{
324-
SentrySdk.CaptureException(e);
325-
}
326-
finally
327-
{
328-
if (connection.State != ConnectionState.Open)
324+
try
325+
{
326+
await PlayerSaveRoutine(obj, ct).ConfigureAwait(false);
327+
return true;
328+
}
329+
catch (Exception e)
329330
{
330-
Console.ForegroundColor = ConsoleColor.Blue;
331-
Console.WriteLine("Reconnecting Player Save-State");
332-
ServerSetup.Instance.ServerSaveConnection = new SqlConnection(ConnectionString);
333-
ServerSetup.Instance.ServerSaveConnection.Open();
331+
SentrySdk.CaptureException(e);
332+
return false;
334333
}
335334
}
336-
337-
return true;
338335
}
339336

340337
/// <summary>
341338
/// Saves all players states
342339
/// Utilizes an active connection that self-heals if closed
343340
/// </summary>
344-
public async Task<bool> ServerSave(List<Aisling> playerList)
341+
public async Task ServerSave(List<Aisling> players, CancellationToken ct = default)
345342
{
346-
if (playerList.Count == 0) return false;
343+
if (players.Count == 0) return;
344+
const int maxConcurrency = 10;
345+
using var throttler = new SemaphoreSlim(maxConcurrency, maxConcurrency);
346+
347+
var tasks = new List<Task>(players.Count);
347348

348-
using (await SaveLock.LockAsync())
349+
foreach (var p in players)
349350
{
350-
var connection = ServerSetup.Instance.ServerSaveConnection;
351+
await throttler.WaitAsync(ct).ConfigureAwait(false);
351352

352-
try
353+
tasks.Add(Task.Run(async () =>
353354
{
354-
const int maxConcurrency = 10;
355-
var semaphore = new SemaphoreSlim(maxConcurrency);
356-
var tasks = new List<Task>();
357-
358-
foreach (var player in playerList.Where(p => p is { Loading: false }))
355+
try
359356
{
360-
await semaphore.WaitAsync();
361-
362-
var task = PlayerSaveRoutine(player, connection).ContinueWith(_ => semaphore.Release());
363-
tasks.Add(task);
357+
await Save(p, ct).ConfigureAwait(false);
364358
}
365-
366-
await Task.WhenAll(tasks);
367-
}
368-
catch (Exception e)
369-
{
370-
SentrySdk.CaptureException(e);
371-
}
372-
finally
373-
{
374-
if (connection.State != ConnectionState.Open)
359+
finally
375360
{
376-
Console.ForegroundColor = ConsoleColor.Blue;
377-
Console.WriteLine("Reconnecting Player Save-State");
378-
ServerSetup.Instance.ServerSaveConnection = new SqlConnection(ConnectionString);
379-
ServerSetup.Instance.ServerSaveConnection.Open();
361+
throttler.Release();
380362
}
381-
}
382-
383-
return true;
363+
}, ct));
384364
}
365+
366+
await Task.WhenAll(tasks).ConfigureAwait(false);
385367
}
386368

387-
private static Task PlayerSaveRoutine(Aisling player, SqlConnection connection)
369+
private async Task PlayerSaveRoutine(Aisling player, CancellationToken ct)
388370
{
389-
if (player.Client == null) return Task.CompletedTask;
390-
player.Client.LastSave = DateTime.UtcNow;
391-
var dt = PlayerDataTable();
392-
var qDt = QuestDataTable();
393-
var cDt = ComboScrollDataTable();
394-
var iDt = ItemsDataTable();
395-
var skillDt = SkillDataTable();
396-
var spellDt = SpellDataTable();
397-
var buffDt = BuffsDataTable();
398-
var debuffDt = DeBuffsDataTable();
399-
dt = PlayerStatSave(player, dt);
400-
qDt = PlayerQuestSave(player, qDt);
401-
cDt = PlayerComboSave(player, cDt);
402-
iDt = PlayerItemSave(player, iDt);
403-
skillDt = PlayerSkillSave(player, skillDt);
404-
spellDt = PlayerSpellSave(player, spellDt);
405-
buffDt = PlayerBuffSave(player, buffDt);
406-
debuffDt = PlayerDebuffSave(player, debuffDt);
407-
408-
using (var cmd = new SqlCommand("PlayerSave", connection))
409-
{
410-
cmd.CommandType = CommandType.StoredProcedure;
411-
var param = cmd.Parameters.AddWithValue("@Players", dt);
412-
param.SqlDbType = SqlDbType.Structured;
413-
param.TypeName = "dbo.PlayerType";
414-
cmd.ExecuteNonQuery();
415-
}
416-
417-
using (var cmd2 = new SqlCommand("PlayerQuestSave", connection))
418-
{
419-
cmd2.CommandType = CommandType.StoredProcedure;
420-
var param2 = cmd2.Parameters.AddWithValue("@Quests", qDt);
421-
param2.SqlDbType = SqlDbType.Structured;
422-
param2.TypeName = "dbo.QuestType";
423-
cmd2.ExecuteNonQuery();
424-
}
371+
await using var conn = new SqlConnection(ConnectionString);
372+
await conn.OpenAsync(ct).ConfigureAwait(false);
373+
await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
425374

426-
using (var cmd3 = new SqlCommand("PlayerComboSave", connection))
427-
{
428-
cmd3.CommandType = CommandType.StoredProcedure;
429-
var param3 = cmd3.Parameters.AddWithValue("@Combos", cDt);
430-
param3.SqlDbType = SqlDbType.Structured;
431-
param3.TypeName = "dbo.ComboType";
432-
cmd3.ExecuteNonQuery();
433-
}
434-
435-
using (var cmd4 = new SqlCommand("ItemUpsert", connection))
436-
{
437-
cmd4.CommandType = CommandType.StoredProcedure;
438-
var param4 = cmd4.Parameters.AddWithValue("@Items", iDt);
439-
param4.SqlDbType = SqlDbType.Structured;
440-
param4.TypeName = "dbo.ItemType";
441-
cmd4.ExecuteNonQuery();
442-
}
443-
444-
using (var cmd5 = new SqlCommand("PlayerSaveSkills", connection))
375+
try
445376
{
446-
cmd5.CommandType = CommandType.StoredProcedure;
447-
var param5 = cmd5.Parameters.AddWithValue("@Skills", skillDt);
448-
param5.SqlDbType = SqlDbType.Structured;
449-
param5.TypeName = "dbo.SkillType";
450-
cmd5.ExecuteNonQuery();
377+
player.Client.LastSave = DateTime.UtcNow;
378+
var dt = PlayerDataTable();
379+
var qDt = QuestDataTable();
380+
var cDt = ComboScrollDataTable();
381+
var iDt = ItemsDataTable();
382+
var skillDt = SkillDataTable();
383+
var spellDt = SpellDataTable();
384+
var buffDt = BuffsDataTable();
385+
var debuffDt = DeBuffsDataTable();
386+
dt = PlayerStatSave(player, dt);
387+
qDt = PlayerQuestSave(player, qDt);
388+
cDt = PlayerComboSave(player, cDt);
389+
iDt = PlayerItemSave(player, iDt);
390+
skillDt = PlayerSkillSave(player, skillDt);
391+
spellDt = PlayerSpellSave(player, spellDt);
392+
buffDt = PlayerBuffSave(player, buffDt);
393+
debuffDt = PlayerDebuffSave(player, debuffDt);
394+
395+
await ExecTvpAsync(conn, tx, "PlayerSave", "@Players", "dbo.PlayerType", dt, ct).ConfigureAwait(false);
396+
await ExecTvpAsync(conn, tx, "PlayerQuestSave", "@Quests", "dbo.QuestType", qDt, ct).ConfigureAwait(false);
397+
await ExecTvpAsync(conn, tx, "PlayerComboSave", "@Combos", "dbo.ComboType", cDt, ct).ConfigureAwait(false);
398+
await ExecTvpAsync(conn, tx, "ItemUpsert", "@Items", "dbo.ItemType", iDt, ct).ConfigureAwait(false);
399+
await ExecTvpAsync(conn, tx, "PlayerSaveSkills", "@Skills", "dbo.SkillType", skillDt, ct).ConfigureAwait(false);
400+
await ExecTvpAsync(conn, tx, "PlayerSaveSpells", "@Spells", "dbo.SpellType", spellDt, ct).ConfigureAwait(false);
401+
await ExecTvpAsync(conn, tx, "BuffSave", "@Buffs", "dbo.BuffType", buffDt, ct).ConfigureAwait(false);
402+
await ExecTvpAsync(conn, tx, "DeBuffSave", "@Debuffs", "dbo.DebuffType", debuffDt, ct).ConfigureAwait(false);
403+
404+
await tx.CommitAsync(ct).ConfigureAwait(false);
451405
}
452-
453-
using (var cmd6 = new SqlCommand("PlayerSaveSpells", connection))
406+
catch (Exception e)
454407
{
455-
cmd6.CommandType = CommandType.StoredProcedure;
456-
var param6 = cmd6.Parameters.AddWithValue("@Spells", spellDt);
457-
param6.SqlDbType = SqlDbType.Structured;
458-
param6.TypeName = "dbo.SpellType";
459-
cmd6.ExecuteNonQuery();
408+
await tx.RollbackAsync(ct).ConfigureAwait(false);
409+
ServerSetup.EventsLogger($"PlayerSave performed rollback!", LogLevel.Error);
410+
SentrySdk.CaptureException(e);
460411
}
412+
}
461413

462-
using (var cmd7 = new SqlCommand("BuffSave", connection))
414+
private static async Task ExecTvpAsync(SqlConnection conn, SqlTransaction tx, string procName,
415+
string paramName, string typeName, DataTable tvp, CancellationToken ct)
416+
{
417+
await using var cmd = new SqlCommand(procName, conn, tx)
463418
{
464-
cmd7.CommandType = CommandType.StoredProcedure;
465-
var param7 = cmd7.Parameters.AddWithValue("@Buffs", buffDt);
466-
param7.SqlDbType = SqlDbType.Structured;
467-
param7.TypeName = "dbo.BuffType";
468-
cmd7.ExecuteNonQuery();
469-
}
419+
CommandType = CommandType.StoredProcedure
420+
};
470421

471-
using (var cmd8 = new SqlCommand("DeBuffSave", connection))
472-
{
473-
cmd8.CommandType = CommandType.StoredProcedure;
474-
var param8 = cmd8.Parameters.AddWithValue("@Debuffs", debuffDt);
475-
param8.SqlDbType = SqlDbType.Structured;
476-
param8.TypeName = "dbo.DebuffType";
477-
cmd8.ExecuteNonQuery();
478-
}
422+
var p = cmd.Parameters.AddWithValue(paramName, tvp);
423+
p.SqlDbType = SqlDbType.Structured;
424+
p.TypeName = typeName;
479425

480-
return Task.CompletedTask;
426+
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
481427
}
482428

483429
private static DataTable PlayerStatSave(Aisling obj, DataTable dt)

Zolian.Server.Base/GameScripts/Areas/Generic/Rift.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public override void Update(TimeSpan elapsedTime)
5555
{
5656
var guardianKillReward = _playersOnMap.RandomIEnum().Value;
5757
guardianKillReward ??= _playersOnMap.RandomIEnum().Value;
58-
ChestGenerator(guardianKillReward.Client);
58+
//ChestGenerator(guardianKillReward.Client);
5959

6060
foreach (var player in _playersOnMap.Values)
6161
{

Zolian.Server.Base/GameScripts/Mundanes/Generic/Banker.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public override void OnGoldDropped(WorldClient client, uint money)
8181
}
8282
}
8383

84-
public override void OnResponse(WorldClient client, ushort responseId, string args)
84+
public override async void OnResponse(WorldClient client, ushort responseId, string args)
8585
{
8686
if (client.Aisling.ActionUsed != "Remote Bank")
8787
if (!AuthenticateUser(client)) return;
@@ -302,7 +302,7 @@ public override void OnResponse(WorldClient client, ushort responseId, string ar
302302
itemsToDelete.Add(item);
303303
}
304304

305-
BankManager.RemoveFromBank(client, itemsToDelete);
305+
await BankManager.RemoveFromBankAsync(client, itemsToDelete);
306306
client.Aisling.BankedGold += offer;
307307
client.SendOptionsDialog(Mundane, $"They're going to send over your {offer} gold and we'll store it here in the bank.");
308308
}

Zolian.Server.Base/Managers/BankManager.cs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Darkages.Network.Client;
77
using Darkages.Network.Server;
88
using Microsoft.Data.SqlClient;
9+
using Darkages.Database;
910

1011
namespace Darkages.Managers;
1112

@@ -33,10 +34,10 @@ public void WithdrawGold(IWorldClient client, ulong gold)
3334
/// Removes the item from bank (Only used when item is pawned in bulk) -
3435
/// Deletes the record from the database
3536
/// </summary>
36-
public static void RemoveFromBank(WorldClient client, List<Item> itemsToDelete)
37+
public static async Task RemoveFromBankAsync(WorldClient client, List<Item> itemsToDelete, CancellationToken ct = default)
3738
{
3839
if (client == null) return;
39-
if (itemsToDelete.Count == 0) return;
40+
if (itemsToDelete == null || itemsToDelete.Count == 0) return;
4041
var iDt = ItemsDataTable();
4142

4243
try
@@ -71,15 +72,20 @@ public static void RemoveFromBank(WorldClient client, List<Item> itemsToDelete)
7172
item.Enchantable,
7273
item.Tarnished,
7374
gearEnhanced,
74-
itemMaterial
75+
itemMaterial,
76+
item.GiftWrapped
7577
);
7678
}
7779

78-
DeleteFromAislingDb(iDt);
80+
using (await StorageManager.AislingBucket.GetPlayerLock(client.Aisling.Serial).LockAsync().ConfigureAwait(false))
81+
{
82+
await DeleteFromAislingDbAsync(iDt, ct).ConfigureAwait(false);
83+
}
7984
}
80-
catch
85+
catch (Exception ex)
8186
{
8287
ServerSetup.EventsLogger($"Issue pawning items from {client.RemoteIp} - {client.Aisling.Serial}");
88+
SentrySdk.CaptureException(ex);
8389
}
8490
}
8591

@@ -109,14 +115,19 @@ private static DataTable ItemsDataTable()
109115
return dt;
110116
}
111117

112-
private static void DeleteFromAislingDb(DataTable iDt)
118+
private static async Task DeleteFromAislingDbAsync(DataTable iDt, CancellationToken ct)
113119
{
114-
var connection = ServerSetup.Instance.ServerSaveConnection;
115-
using var cmd4 = new SqlCommand("ItemMassDelete", connection);
116-
cmd4.CommandType = CommandType.StoredProcedure;
117-
var param4 = cmd4.Parameters.AddWithValue("@Items", iDt);
118-
param4.SqlDbType = SqlDbType.Structured;
119-
param4.TypeName = "dbo.ItemType";
120-
cmd4.ExecuteNonQuery();
120+
await using var conn = new SqlConnection(AislingStorage.ConnectionString);
121+
await conn.OpenAsync(ct).ConfigureAwait(false);
122+
123+
await using var cmd = new SqlCommand("ItemMassDelete", conn)
124+
{
125+
CommandType = CommandType.StoredProcedure
126+
};
127+
128+
var p = cmd.Parameters.AddWithValue("@Items", iDt);
129+
p.SqlDbType = SqlDbType.Structured;
130+
p.TypeName = "dbo.ItemType";
131+
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
121132
}
122133
}

Zolian.Server.Base/Managers/InventoryManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public void RemoveFromInventory(WorldClient client, Item item)
118118
}
119119
finally
120120
{
121-
_ = item.DeleteFromAislingDb();
121+
_ = item.DeleteFromAislingDb(client.Aisling.Serial);
122122
}
123123
}
124124

0 commit comments

Comments
 (0)