fix(shared): make WizardDraftRepository AOT-safe (v3.9.1 hotfix)
Production regression in 3.9.0: Telegram bot silently dropped every update. WizardDraftRepository.GetActiveAsync was called on every Telegram update (via UpdateRouter -> TryGetWizardContext) and threw System.PlatformNotSupportedException in NativeAOT, because Dapper.AOT 1.0.48 only generates interceptors for the (sql, object?) extension overloads and NOT for the (CommandDefinition) overload. The runtime then fell back to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and fails on AOT. TelegramBotService swallowed the exception, so /newsession appeared to start but no button press reached the wizard and no session was created. Two related changes in WizardDraft: 1. Switched WizardDraftRepository.* from 'new CommandDefinition(sql, params, cancellationToken: ct)' to the direct 'connection.Query*(sql, params)' overload, matching the working pattern in JoinSessionHandler. Dapper.AOT now generates CommandFactory30<WizardDraft> + RowFactory17<WizardDraft> + QuerySingleOrDefaultAsync37<WizardDraft> for all four methods. 2. WizardDraft.CreatedAt/UpdatedAt/ExpiresAt are now DateTime (UTC) instead of DateTimeOffset. AOT RowFactory calls reader.GetDateTime() directly and does not perform DateTime -> DateTimeOffset conversion; the previous type raised InvalidCastException on the very first wizard_drafts query. All 588/590 tests pass (2 pre-existing skipped, +5 new AOT regression tests in WizardDraftRepositoryAotShapeTests). dotnet format clean. Bumps: 3.9.0 -> 3.9.1. Note: GetOwnerClubsAsync (Telegram/Discord), DiscordPermissionLookup, and DiscordWizardInteractionModule.GetOwnerClubsAsync still use CommandDefinition and will hit the same Reflection.Emit AOT failure when the user reaches the PickClub visibility step. Follow-up in 3.9.2.
This commit is contained in:
@@ -66,16 +66,16 @@ public sealed class CreateSessionHandler
|
||||
OwnerId = ownerId,
|
||||
Platform = PlatformName,
|
||||
Step = WizardStepNames.Type,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
};
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||
draft.DraftMessageId = msgId;
|
||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
return draft;
|
||||
}
|
||||
@@ -135,7 +135,7 @@ public sealed class CreateSessionHandler
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
return;
|
||||
}
|
||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
|
||||
@@ -115,9 +115,9 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
|
||||
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
|
||||
? WizardStepNames.Title
|
||||
: WizardStepNames.Type,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
};
|
||||
// If the user passed `mode=pool` we pre-seed the payload so the
|
||||
// wizard's own branching lands on the pool flow.
|
||||
@@ -147,7 +147,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
|
||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
|
||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||
draft.DraftMessageId = msgId;
|
||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(msg =>
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed class DiscordWizardSubmitter
|
||||
_contextStore.Remove(draft.Id);
|
||||
return;
|
||||
}
|
||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
// The full exception (with stack trace, Postgres constraint
|
||||
// name, sometimes partial SQL) is already logged server-side
|
||||
|
||||
@@ -169,7 +169,7 @@ public sealed class GameCreationWizard
|
||||
|
||||
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
|
||||
{
|
||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
draft.UpdatedAt = DateTime.UtcNow;
|
||||
await _drafts.UpsertAsync(draft, ct);
|
||||
var payload = LoadPayload(draft);
|
||||
IReadOnlyList<WizardClubOption>? clubs = null;
|
||||
|
||||
@@ -43,9 +43,9 @@ public sealed class WizardDraft
|
||||
/// </summary>
|
||||
public string? DraftMessageId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
||||
{
|
||||
// NOTE: NativeAOT — Dapper.AOT 1.0.48 only generates interceptors for the
|
||||
// (sql, param) extension overloads, NOT for the (CommandDefinition) overload.
|
||||
// Passing a CommandDefinition here would skip the interceptor and fall back to
|
||||
// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
|
||||
// throws PlatformNotSupportedException on AOT (issue: wizard silently dropped
|
||||
// every Telegram update in v3.9.0). All four methods therefore use the plain
|
||||
// (sql, param) overload, matching the pattern in JoinSessionHandler.
|
||||
public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
@@ -29,13 +36,10 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new { Platform = platform, OwnerId = ownerId },
|
||||
cancellationToken: ct));
|
||||
sql,
|
||||
new { Platform = platform, OwnerId = ownerId });
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
|
||||
@@ -52,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
expires_at = EXCLUDED.expires_at;
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
|
||||
await connection.ExecuteAsync(sql, draft);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||
await connection.ExecuteAsync(sql, new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
|
||||
{
|
||||
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
|
||||
return await connection.ExecuteAsync(sql);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.9.0</div>
|
||||
<div class="nav-version">v3.9.1</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
Reference in New Issue
Block a user