sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

PlanetScale KeyNotFoundException が発生する問題と対処

Planetscale を利用していたのですが、ある日突然、何もしていないのに前日まで動作していた自作のアプリケーションが動かなくなりました。

「何もしていないのに壊れた!」っていうのは、パソコン初心者が故障時に発する常套句でもありますが、1年も前に発行されているアプリケーションがある日から突然動作しなくなるというのは、珍しい経験で結構驚きました。

状況を正確にすると、クラウド上の DB アクセスをするアプリケーションであったこと。まったく動作しないわけではなくて select 文は問題なく実行できました。しかし insert/update 文を実行すると KeyNotFoundException の例外が発生してしまうとわかりました。

結論としては、PlanetScale の DB とデータの書き込みをする際の transaction でエラーが発生していて、原因はデータベース プロバイダーにあることがわかりました。

この記事は、KeyNotFoundException の問題を解決するために実施した対処方法を記録しています。

おそらく問題の発生したバージョン

対処1:データベース プロバイダーを変更する

完成しているアプリケーションのパッケージを入れ替えするのは、正直のところ気が進みませんでした。なので、なにか情報はないかと以下で質問を投げてみたのですが、(私にとって楽な)都合のよい回答が得られなかったので、データベース プロバイダーを変更することにしました。

幸いというか、バージョン管理をしていれば、何かあっても元に戻すことはできるだろう、という点で心配をしていませんでした。

私の場合はもともと以下のパッケージを利用していました。

EF6 に MySQL のEF + データベース プロバイダーを追加した形です。もともと .NET Framework のアプリケーションだったものを .NET に移植したような形だったので EFcore にも対応していませんでした。

で、入れ替え作業で、以下のようになりました。

EFcore + Pomelo.core の単純な構成になりました。コード修正で大きい違いがあるのではないかと懸念したのですが、後述のようにそれほど大問題になりませんでした。

むしろ、データベースに保存された null の値などの処理が MySQL.core/Pomelo.core で微妙に違うことがあり、その点で面倒がありました。(Pomelo.core のほうが厳しく MySQL.core は変換が寛容でした)

変更点1:初期化

EF6 の場合は、DbConfiguration または XAML で利用する DB プロバイダー/DLL を設定すると思います。

EF 6 の初期化

public class MySqlConfiguration : DbConfiguration
{
    public MySqlConfiguration()
    {
        var name = MySqlProviderInvariantName.ProviderName;

        SetDefaultConnectionFactory(new SqlConnectionFactory());
        SetProviderFactory(name, new MySql.Data.MySqlClient.MySqlClientFactory());
        SetProviderServices(name, new MySql.Data.MySqlClient.MySqlProviderServices());
    }
}

[DbConfigurationType(typeof(MySqlConfiguration))]
public class MySqlContext : DbContext
{
    public static string ConnectionString { get; set; } = "";
    public DbSet<SystemHistory> SystemHistory => Set<SystemHistory>();
    public MySqlContext() : base(new MySqlConnection(ConnectionString), true) { }
}

EFcore

EFcore の場合、一例ですが OnConfiguring で設定します。UseMySql の部分が Pomelo.core で定義されていて、OnConfiguring なんかの部分は EFcore の部分で用意されている形です。

なので、MySQL.core をインストールすると UseMySQL メソッド(SQL が大文字)になったりします。拡張をそれぞれ用意している形ですね。

public class MySqlContext : DbContext
{
    public static string ConnectionString { get; set; } = "";
    public DbSet<SystemHistory> SystemHistory => Set<SystemHistory>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        var version = new MySqlServerVersion(new Version(8, 0, 23));

        optionsBuilder.UseMySql(ConnectionString, version, options => { });
        optionsBuilder.LogTo(msg => System.Diagnostics.Debug.WriteLine(msg));
    }
}

細かい注意点として、Pomelo.core は DB のバージョンを指定します。Pomelo.core のバージョンごとに対応する latest(最新)バージョンの上限が設定されていたりします。

PlanetScale でも、Console なんかで DB のバージョンを確認することができますので、このバージョンを設定するようにしよう。

select version();

ただし、クラウドサービスなので、バージョンは更新される可能性があるので、.env ファイルなんかで変更可能なようにしておこう。

変更点2:型指定を丁寧にする

データベースに保存されている null の値があるとします。

CREATE TABLE `system_history` (
    `id` int NOT NULL AUTO_INCREMENT,
    `version` varchar(32),
    PRIMARY KEY (`id`)
)

保存されているデータ:

id version
1 null

ORMはこんな感じとします。

[Table("system_history")]
public class SystemHistory
{
    [Column("id")]
    public int Id { get; set; }

    [Column("version")]
    public string Version { get; set; } = "";
}

取得するコードはこんな感じとします。

public async Task<SystemHistory?> SelectSystemHistoryAsync(int id)
    {
        using (var context = new PlanetscaleDbContext())
        {
            var sys = await context.SystemHistories
                .SingleOrDefaultAsync(p => p.Id == id);

            if (sys == null)
            {
                Debug.WriteLine($"指定した id の履歴が null でした。id = {id}.");
                return null;
            }

            return sys;
        }
    }

MySql.Data.EntityFrameworkCore だとこのコードでも問題なく動作してしまいます。しかし、string 型の Version に null が代入されてしまう点に注目。

たしかに、C#string 型は null を代入できてしまうのですが、現在の C# は null の扱うときは string? 型のように指定することが推奨になります。(C# 8.0 から null の可否を明示するようになった

なんで、Pomelo.EntityFrameworkCore.MySql では、以下の例外テキストのように DB.Null 値を string 型に変換できなかった、としてエラーになってしまいます。

Unable to cast object of type 'System.DBNull' to type 'System.String'

対処方法は当然、null を許容してよいなら string? 型にすることです。

[Table("system_history")]
public class SystemHistory
{
    [Column("id")]
    public int Id { get; set; }

    [Column("version")]
    public string? Version { get; set; } = "";
}

そもそも DB に null が入っていることが好ましくないなら insert のタイミングで "" を入れるようにします。また、update 文で null 値を "" に変更する。(ただし、DB 全体に update をかけるので、バックアップに注意!)

変更点3:使用する名前空間に注意

EF6 の名前空間 using System.Data.Entity; から更新するときは、EFcore の名前空間 using Microsoft.EntityFrameworkCore; に変更するのを忘れないようにしよう。

両者のメソッド名なんかは、殆ど一緒なので、EFcore を利用しているにも関わらず間違って EF6 の名前空間を指定していてもコンパイルが通ってしまうことがあります。(同じアプリケーションで EF Core と EF6 の使用することもできるため)

もちろん実行時には、間違った組み合わせだとエラーが発生してしまうけど、発生しているエラーのテキストから原因を特定できない/しづらい。

参考