Skip to content

Commit fb606bd

Browse files
committed
Harden PlayerSave with transient SQL retry logic
1 parent 1094727 commit fb606bd

File tree

3 files changed

+90
-47
lines changed

3 files changed

+90
-47
lines changed
-4 Bytes
Binary file not shown.

Zolian.Server.Base/Database/AislingStorage.cs

Lines changed: 54 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -368,49 +368,57 @@ public async Task ServerSave(List<Aisling> players, CancellationToken ct = defau
368368

369369
private async Task PlayerSaveRoutine(Aisling player, CancellationToken ct)
370370
{
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);
374-
375-
try
371+
var ok = await ExecuteWithRetryAsync(async token =>
376372
{
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, "PlayerBuffSync", "@Buffs", "dbo.BuffType", buffDt, ct, new SqlParameter("@Serial", SqlDbType.BigInt) { Value = (long)player.Serial }).ConfigureAwait(false);
402-
await ExecTvpAsync(conn, tx, "PlayerDeBuffSync", "@Debuffs", "dbo.DebuffType", debuffDt, ct, new SqlParameter("@Serial", SqlDbType.BigInt) { Value = (long)player.Serial }).ConfigureAwait(false);
403-
404-
await tx.CommitAsync(ct).ConfigureAwait(false);
405-
406-
// Player has a synced database state
407-
player.PlayerSaveDirty = false;
408-
}
409-
catch (Exception e)
373+
await using var conn = new SqlConnection(ConnectionString);
374+
await conn.OpenAsync(token).ConfigureAwait(false);
375+
await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, token).ConfigureAwait(false);
376+
377+
try
378+
{
379+
player.Client.LastSave = DateTime.UtcNow;
380+
381+
var dt = PlayerStatSave(player, PlayerDataTable());
382+
var qDt = PlayerQuestSave(player, QuestDataTable());
383+
var cDt = PlayerComboSave(player, ComboScrollDataTable());
384+
var iDt = PlayerItemSave(player, ItemsDataTable());
385+
var skillDt = PlayerSkillSave(player, SkillDataTable());
386+
var spellDt = PlayerSpellSave(player, SpellDataTable());
387+
var buffDt = PlayerBuffSave(player, BuffsDataTable());
388+
var debuffDt = PlayerDebuffSave(player, DeBuffsDataTable());
389+
390+
await ExecTvpAsync(conn, tx, "PlayerSave", "@Players", "dbo.PlayerType", dt, token).ConfigureAwait(false);
391+
await ExecTvpAsync(conn, tx, "PlayerQuestSave", "@Quests", "dbo.QuestType", qDt, token).ConfigureAwait(false);
392+
await ExecTvpAsync(conn, tx, "PlayerComboSave", "@Combos", "dbo.ComboType", cDt, token).ConfigureAwait(false);
393+
await ExecTvpAsync(conn, tx, "ItemUpsert", "@Items", "dbo.ItemType", iDt, token).ConfigureAwait(false);
394+
await ExecTvpAsync(conn, tx, "PlayerSaveSkills", "@Skills", "dbo.SkillType", skillDt, token).ConfigureAwait(false);
395+
await ExecTvpAsync(conn, tx, "PlayerSaveSpells", "@Spells", "dbo.SpellType", spellDt, token).ConfigureAwait(false);
396+
await ExecTvpAsync(
397+
conn, tx, "PlayerBuffSync", "@Buffs", "dbo.BuffType", buffDt, token,
398+
new SqlParameter("@Serial", SqlDbType.BigInt) { Value = (long)player.Serial }).ConfigureAwait(false);
399+
await ExecTvpAsync(
400+
conn, tx, "PlayerDeBuffSync", "@Debuffs", "dbo.DebuffType", debuffDt, token,
401+
new SqlParameter("@Serial", SqlDbType.BigInt) { Value = (long)player.Serial }).ConfigureAwait(false);
402+
403+
await tx.CommitAsync(token).ConfigureAwait(false);
404+
player.PlayerSaveDirty = false;
405+
}
406+
catch
407+
{
408+
try
409+
{
410+
await tx.RollbackAsync(token).ConfigureAwait(false);
411+
}
412+
catch { }
413+
414+
// Throw and let ExecuteWithRetryAsync see the SqlException
415+
throw;
416+
}
417+
}, ct).ConfigureAwait(false);
418+
419+
if (!ok)
410420
{
411-
await tx.RollbackAsync(ct).ConfigureAwait(false);
412-
ServerSetup.EventsLogger($"PlayerSave performed rollback!", LogLevel.Error);
413-
SentrySdk.CaptureException(e);
421+
ServerSetup.EventsLogger($"PlayerSave failed after retries for Serial={player.Serial}", LogLevel.Error);
414422
}
415423
}
416424

@@ -419,10 +427,10 @@ private static async Task ExecTvpAsync(SqlConnection conn, SqlTransaction tx, st
419427
{
420428
await using var cmd = new SqlCommand(procName, conn, tx)
421429
{
422-
CommandType = CommandType.StoredProcedure
430+
CommandType = CommandType.StoredProcedure,
431+
CommandTimeout = 5
423432
};
424433

425-
// Add scalar/extra parameters first
426434
if (extraParams is { Length: > 0 })
427435
{
428436
foreach (var ep in extraParams)
@@ -432,10 +440,9 @@ private static async Task ExecTvpAsync(SqlConnection conn, SqlTransaction tx, st
432440
}
433441
}
434442

435-
// Add TVP parameter
436-
var p = cmd.Parameters.AddWithValue(tvpParamName, tvp);
437-
p.SqlDbType = SqlDbType.Structured;
438-
p.TypeName = typeName;
443+
var tvpParam = cmd.Parameters.Add(tvpParamName, SqlDbType.Structured);
444+
tvpParam.TypeName = typeName;
445+
tvpParam.Value = tvp;
439446

440447
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
441448
}

Zolian.Server.Base/Database/Sql.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,40 @@ protected static void ExecuteAndCloseConnection(SqlCommand command, SqlConnectio
2727
command.ExecuteNonQuery();
2828
conn.Close();
2929
}
30+
31+
public static async Task<bool> ExecuteWithRetryAsync(Func<CancellationToken, Task> action, CancellationToken ct, int maxAttempts = 4, int baseDelayMs = 40)
32+
{
33+
for (var attempt = 1; attempt <= maxAttempts; attempt++)
34+
{
35+
try
36+
{
37+
await action(ct).ConfigureAwait(false);
38+
return true;
39+
}
40+
catch (SqlException ex) when (IsTransient(ex) && attempt < maxAttempts)
41+
{
42+
var delayMs = Math.Min(500, baseDelayMs * (1 << (attempt - 1)));
43+
// Randomized exponential backoff
44+
delayMs += Random.Shared.Next(0, 25);
45+
46+
await Task.Delay(delayMs, ct).ConfigureAwait(false);
47+
}
48+
}
49+
50+
return false;
51+
}
52+
53+
private static bool IsTransient(SqlException ex)
54+
{
55+
foreach (SqlError err in ex.Errors)
56+
{
57+
// -2 = Command timeout
58+
// 1205 = Deadlock victim
59+
// 1222 = Lock request timeout
60+
if (err.Number is -2 or 1205 or 1222)
61+
return true;
62+
}
63+
64+
return false;
65+
}
3066
}

0 commit comments

Comments
 (0)