Programming

Cloudflare上でトランザクションが成功している(ように見える)のに、DBに反映されていない現象に遭遇した

Cloudflareで動作する、個人開発プロジェクトで、次のような問題に遭遇しました。

手元マシンの開発環境でnpm run devで実行し、ブラウザでアクセスしているときは、バックエンド処理でDBトランザクションが行われて成功し、(当然)DBも更新されるのに、いざCloudflare同じ処理を動かすと、DBトランザクションが成功している(ように見える)のに、DBが更新されていない。

まず、Cloudflareの実行時間制限にかかっているのか、と疑いましたが、エラーも全く起きていないので、これが原因ではなさそうでした。

アプリケーション上で他の箇所でもトランザクションを使っているところがあり、それらはCloudflareとローカル開発環境で動作の違いは無いのに、ある特定のリクエストだけ現象が起きていました。

ざっくりと、以下のような処理です。

const deleteById = async (db: TiDBServerlessDatabase<typeof schema>, form: FormData) => {
    const id = form.get('id')
    try {
        db.transaction(async (tx) => {
            await tx.delete(TableA).where(eq(TableA.id, id))
            await tx.delete(TableB).where(eq(TableB.id, id))
        })
    } catch (e) {
        return { status: 'fail', msg: e }
    }
    return { status: 'success', msg: '削除しました' }
}

Remixのactionとして動作しており、returnが行われると、HTTP応答がブラウザに帰ります。

上記コードの問題は何でしょうか???? 正解は↓

const deleteById = async (db: TiDBServerlessDatabase<typeof schema>, form: FormData) => {
    const id = form.get('id')
    try {
        await db.transaction(async (tx) => {
            await tx.delete(TableA).where(eq(TableA.id, id))
            await tx.delete(TableB).where(eq(TableB.id, id))
        })
    } catch (e) {
        return { status: 'fail', msg: e }
    }
    return { status: 'success', msg: '削除しました' }
}

db.transactionの前にawaitが抜けていました。それで、実際の動作順としてはreturnでHTTP応答が行われた後にトランザクションが動作していたようです。Cloudflareだと、HTTPリクエストが完了すると、直ちにworkerのインスタンスが破棄されるようで、トランザクションが終わるところまで処理が行われていなかったと思われます。手元のNode.jsだと当然そういうこともないので、awaitが抜けていてもトランザクションが完了し、DBが更新されていたわけです。

Remixでは副作用のためのモジュールインポートは避けた方が良い

引き続きRemixで開発していたところ、クラアント側で動作させたいコードで、副作用モジュールインポートを行っていると問題が起きることがわかりました:

import 'hoge'

上記のようなimportです。このimportを行う側のコードでは、hogeの中身を名前で参照していませんので、このimportは、hogeのロード時1度限りのコード実行を目的としたものです。

今回このhogeの中身では、グローバルオブジェクトに新しいプロパティを付け加える処理を行っています。

$ npm run dev

で開発しているときには、上記のimportを行うコードは正しく実行されていたのですが、

$ npm run build
$ npm run start

にて、ビルドした成果物を動かすと、hogeが全く動作していませんでした(hogeで付け加えられるべきプロパティが存在しなかったため、エラーが起きた)。

散々悩んだ挙げ句、Module Constraintsで説明されている通り、Remixで上記のような副作用を目的としたモジュールのimportは行わない方が良さそうです。

今回の目的では、hogeの実行はimport時ではなく、ページ初期化後でも構わなかったため、hogeの中身をexport functionとし、副作用モジュールインポートを、関数のインポートに置き換えました。そして、当該functionをuseEffectの中から呼び出すようにました。この方法は、Lazy Initializationで紹介されているものです。

動作確認したバージョン

  • @remix-run/cloudflare: 2.10.3
  • remix-utils: 7.6.0
  • react: 18.3.1

RemixでSSRをバイパスするにはremix-utilsを使おう

Remixleafletを使用する開発で、SSRフレームワーク定番(参考1, 参考2)の"window is not defined"がサーバ側の端末で表示される現象に遭遇しました。

要するに、ブラウザでのみ動かしたいコードがサーバ側のSSRで動いてしまっているわけです。

Next.jsだとdynamicで囲って対処していましたが、Remixでどうやってやるのか調べました。

Remix Viteで一部をクライアントサイドのみにするでは、ReactのlazyとSuspenseを組み合わせて行けるとのことでしたが、私が試した限りではうまくいきませんでした(やり方が悪かっただけかもしれません)。

結局、remix-utilsClientOnlyを使って解決しました。

動作確認したバージョン

  • @remix-run/cloudflare: 2.10.2
  • remix-utils: 7.6.0
  • react: 18.3.1

フロントエンド界隈は開発が活発なので、この記事の内容もあっという間に陳腐化するかもしれませんが、備忘録として残しておきます。

MySQLもしくはPostgreSQLで, 1文で2行の特定のカラムの値をスワップする方法

DBに入った2行の、特定のカラムの値をスワップする方法。ちょっと日本語で検索しても良いのが出てこなかったけど、英語で良いのが出てきた(下記はMySQL限定):

Mysql: Swap data for different rows

UPDATE tbl a INNER JOIN tbl b ON a.id <> b.id
SET a.col = b.col
WHERE a.id IN (2, 3)
  AND b.id IN (2, 3)

上記は、テーブルtblのカラムcolの中身を、id=2,3のレコードでスワップする。

これ最初に思いついた人、頭良い。そもそもupdate文でこういうjoinが書けるのを正確に知らなかった。SQL奥深し。updateで使える構文を一度しっかり調べたほうが良さそう。

2023/5/23追記:PostgreSQLでは上記の方法では動かない。次のような感じでいける(参考資料:PostgreSQL, Swap data of certain column in two rows)

UPDATE tbl a set col = b.col
FROM tbl b
WHERE a.id IN (2, 3)
  AND b.id IN (2, 3)
  AND a.id <> b.id

ただ、実際に動作させてみると、colにUNIQUE制約がかかっている とエラーになってしまう。どうやらPostgreSQLの場合、内部的には同時に交換というよりは逐次で更新されているっぽい。さらにちなみに、上記のset colのところをset a.colとすると構文エラーになってしまうので注意。

GitHub CodespacesでのNext.jsアプリ開発で調査した資料

GitHub Codespaces便利ですよね。無料枠だと一ヶ月あたり120時間使用できますが、本業とは別にちょっと開発するのであれば、これくらいの時間があれば十分に感じます。

CodespacesでNext.js/NextAuth.jsでOAuthを使用したWebアプリケーションを作成して、Codespacesで自動的に割り当てられるHTTPSのURLをコールバックに指定できるので、ログインが必要なアプリも開発できます。

ただ、色々やっているうちに、502 Bad Gatewayエラーが出るようになってしまいました。ちょっと検索してみたら、Port Forwarding returns 502 Bad Gatewayに従って、一旦Codespacesのインスタンスを削除して、再度作成したら解消しました… と思ったら、それでも502が続くようになってしまいました… 2023/3/29現在、解決方法は見つかっていません。

RDBはprismaを使用してアクセスしているのですが、検索条件として同一テーブルの異なるカラム同士間の条件で行を抽出したいと思った時、その方法がわかりませんでした。こちらも調べてみたら、Compare columns in the same tableを見つけました。generator clientの中にpreviewFeatures = [“fieldReference”]を追加してprisma generateすると、prisma.table.fields.columnといた感じでカラムを条件部で参照できるようになりました(tableがテーブルに対応するスキーマ名で、columnが参照したいカラム名)。ドキュメントではversion 4.3.0から使えるようになっていると書かれていますが、手元では4.11では動かなかったように思います。4.12に上げたらfieldsが出てきました。

ORMについて変節した

ORM (Object-relational mapping)については賛否両論あると思います。 私もこの件について思うところが最近あったので、ポエムを投下します。

「ORM SQL」といった感じで検索してみると

のような記事が見つかりました。不要とほぼ断言しているものから、積極的にORMを使う派の方まで。

私もこれまではどちらかといえば、「別にSQLで書けるのに、敢えてORMを使う必要性は無いのでは」と思っていました。 ただ、最近開発をしている際に、初めてORMを使いたくなる場面に遭遇しました。

これは、端的に言えば、上の3番目の回答に挙げられているORMの機能

  • 対応するオブジェクトを通じたレコードの更新、トランザクションの一定の隠蔽

に大きな有用性を感じたためです。

これまでの開発では、コードの開発前にRDBのスキーマがほとんど決まっていたので、 レコードの更新や取得を行うSQLは(手間でも)一回だけ書けば良い場面ばかりでした。

でも、今回経験した開発では、要件が完全に決まっていない段階で開発をスタートしたのもあり、 開発がある程度進んだ段階で要件が精緻化されたところ、(ER図でいうところの)エンティティのテーブルとの対応が大幅に変わる可能性が生じました。 具体的には、現実との整合性を保つために

  • あるエンティティは、これまでの単一のテーブルから、複数のテーブルの結合とした方が良い
  • これまで2つのテーブルにマップされていたエンティティは3つのテーブルの結合とした方が良い

といった状況です。

さて、これまで真心込めたSQLで結合してマッピングしていたエンティティですが、 いざRDBスキーマが変更になると、これらも書き直さなければいけません。当然、検索(SELECT)だけでなく、 更新(UPDATE)もです。多:多でエンティティの属性を取得するためにforループを回している記述も修正する必要があります。

困った… というわけでORMの出番です。実現方法は静的コード生成だったり、実行時のリフレクションだったりしますが、 基本的にORMではエンティティ間の1:多などの関係をメタデータとして記述することで、 関数一撃で関連するエンティティを取得できるようになるわけです。

もちろん、ORMでも、RDBスキーマが変わって際の変更がゼロとはいきませんが、複数のSQLやfor文を正しく書き直す労力を考えれば、 メタデータの更新と関数呼び出しの変更程度で対応できるのであれば、相当有用であると思いました。

まとめると、スキーマが完全に決まっており、将来的にも変更されることは(ほぼ)無い、というのであれば、 SQLのみで記述してもそれほど問題にはならないかも知れません。 一方で、要件に伴ってRDBスキーマが開発中にも変更される可能性があるなら、 ORMを使用するのは保険として大変役立つのではないか、と思いました。

SQLの条件部でif-then-elseを行う

SQLでwhere検索を行うときに、条件によって、さらに検索条件を切り替えたいということが生じることがあります。

例えば、テーブルにカラムC1, C2, C3が存在するとして、気持ちとして以下のように書きたくなる場合です。

select something from tbl where if p(C1) then q(C2) else r(C3)

カラムC1に依存する述語p(C1)がTRUEの時はq(C2)で検索し、FALSEの場合はr(C3)で検索する、というものです。

SQL: If clause within WHERE clause に答えがでていますが、この場合は、

select something from tbl where (p(C1) and q(C2)) or (not p(C1) and r(C3))

が答えになります。if p(C1) then q(C2) else r(C3)がTRUEになる場合を考えると、p(C1)がTRUEであれば、q(C2)がTRUEである必要があり、 p(C1)がFALSEであればr(C3)がTRUEでないといけません。これを論理式で書いたのが上になります。

あるいは、if p(C1) then q(C2) else r(C3)はp(C1)→q(C2)かつ¬p(C1)→r(C3)と書き直すこともできるので、 A→B = ¬A ⋁ Bを使って、

(not p(C1) or q(C2)) and (p(C1) or r(C3))

と書いても同じ結果になります。

今回の例では説明を具体的するためにカラムC1, C2, C3と書きましたが、大切なのは述語p, q, rですので、 カラムとは無関係に条件を書くことができます。

2023/1/6追記: SQLのwhere句でifで条件文を複数切り替えは不可 case式で代替可能に説明されていますが、whereの中でcaseを使うこともできるので、

select something from tbl where case when p(C1) then q(C2) else r(C3) end

と書くこともできますね。

愚直なRTLでソートを記述する

FPGAの部屋の記事、 RGB 24ビット・データ入出力対応のメディアン・フィルタをVitis HLS 2021.1で作成する1RGB 24ビット・データ入出力対応のメディアン・フィルタをVitis HLS 2021.1で作成する2にて、HLS記述にてバブルソートを記述し、Interval=1を得ている(1クロック毎にデータを入力できるパイプラインで動作)のを見て、 実はHLSではなく、愚直にRTLで書いてもInterval=1は達成できるのではないかと思い、実験してみました。

ソートをハードウェアで実装するための手法はよく研究されており、ソーティングネットワークと呼ばれているようです。

Wikipediaによれば、3x3(=9)画素のソートを行うためには、7段のソーティングネットワークが最小の段数であるそうです。

“sorting network generator"などと検索すると、ソーティングネットワークの自動生成ツールなどもあり、興味がある方には面白いかもしれません。

書いてみたRTL記述はこちら:

module sort (
    input            CLK,
    input [15:0]     in[0:8],
    output reg[15:0] out
);

    logic[15:0] tmp;
    logic[15:0] in_val[0:8];
    logic[15:0] result;

    int i,j;

    always @(*) begin
        in_val = in;
        for (i = 1; i < 9; i = i + 1) begin
            for (j = 0; j < 9 - i; j = j + 1) begin
                if (in_val[j] < in_val[j+1]) begin
                    tmp         = in_val[j];
                    in_val[j]   = in_val[j+1];
                    in_val[j+1] = tmp;
                end
            end
        end
        result = in_val[4];
    end

    always_ff @(posedge CLK) begin
        out <= result;
    end

endmodule

9個の数値(各16bit)からなる配列inを、上記記事ではC言語で書かれているのと同じ方法でソートしています。 このようなコードは、ある程度RTL記述に慣れた人の方が違和感を感じるかと思います。 ノンブロッキング代入(<=)ではなくブロッキング代入(=)を使っています。

HaskellでのWebSocketsサーバの効率改善

前回の記事で、HTMLのCanvas要素とWebSocketsの組み合わせで画像転送と表示の実験を行いました。 想定よりもHaskellで作ったWebSocketsサーバの動作が重いため、少し調査して改善しました。

import Control.Exception (finally)
import Control.Monad (forM_, forever)

import qualified Network.WebSockets as WS
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Internal as BI

import Foreign.Ptr (plusPtr, Ptr)
import Foreign.Storable (poke)
import Data.Word (Word8)

main :: IO ()
main = WS.runServer "127.0.0.1" 9160 application

-- BI.create :: Int -> (Ptr Word8 -> IO ()) -> IO ByteString
fill :: Int32 -> Ptr Word8 -> IO ()
fill seed ptr = 
    go (0, 0) ptr
      where
        go (j, i) ptr = do
            let val = fromIntegral $ seed + i + j
            poke ptr val
            if i == 1919 then
                if j == 1079 then
                    return ()
                else
                    go (j+1, 0) $ ptr `plusPtr` 1
            else
                go (j, i+1) $ ptr `plusPtr` 1

application :: WS.ServerApp
application pending = do
    conn <- WS.acceptRequest pending
    WS.forkPingThread conn 30
    forever $ do
        bytes <- WS.receiveData conn
        let seed:_ = L.unpack bytes
        let seed2 = fromIntegral (toInteger seed)
        buf <- BI.create (1920*1080) (fill seed2)
        WS.sendBinaryData conn buf

ByteString.Internalのcreate関数を使って、転送するバッファの生成と初期化を同時に行います。 fill関数には、createで確保される領域へのポインタが渡されるので、poke関数を使って当該領域に1つずつデータを書き込みます。 go関数で、1画面分ループします。1ループごとにポインタを1進める(plusPtr)ことで、次のWord8にアクセスできます。

HTMLのCanvasとHaskellでのWebSocketsを組み合わせる

前回、HTMLでCanvasを使用した描画を試しました。 今回は、HaskellでWebSocketsのサーバを作成し、当該サーバからバイナリデータを受信してCanvasに描画するようにしました。

まずは、ブラウザで動作させるファイルです。

<html>
    <body>
        <script
            src="https://code.jquery.com/jquery-3.2.1.js"
            integrity="sha256-DZAnKJ/6XZ9si04Hgrsxu/8s717jcIzLy3oi35EouyE="
            crossorigin="anonymous"></script>
        <p><div id="time">Time[ms]</div></p>
        <canvas id="canvas" width="1920" height="1080"></canvas>
        <script type="text/javascript">
            $(window).on('load', function(){
                var tm = 0;
                var canvas = $('#canvas').get(0);
                var width = canvas.width;
                var height = canvas.height;
                var ctx = canvas.getContext('2d');
                var imageData = ctx.getImageData(0, 0, width, height);

                var buf = new ArrayBuffer(imageData.data.length);
                var buf8 = new Uint8ClampedArray(buf);
                var data = new Uint32Array(buf);
                var start_ms = performance.now();
                var elapsed_ms;
                var cn = new WebSocket('ws://127.0.0.1:9160/');

                cn.addEventListener('message', function(ev){
                    var reader = new FileReader();
                    reader.addEventListener("loadend", function(){
                        var ary = new Uint8Array(reader.result);
                        //console.log(ary[0]);
                        for (var j = 0; j < height; j++){
                            for (var i = 0; i < width; i++){
                                var val = ary[j*width+i];
                                data[j*width+i] = (255<<24) | (val<<16) | (val<<8) | val;
                            }
                        }
                        imageData.data.set(buf8);
                        ctx.putImageData(imageData, 0, 0);
                        if (tm % 60 == 0){
                            var current = performance.now();
                            elapsed_ms = current - start_ms;
                            $("#time").text(elapsed_ms/60.0 + "[ms]");
                            start_ms = current;
                        }
                    });
                    reader.readAsArrayBuffer(ev.data);
                });

                var getData = function(){
                    var abuf = new ArrayBuffer(1);
                    var view = new Uint8Array(abuf);
                    view[0] = tm & 0xFF;
                    cn.send(abuf);
                    tm++;
                    setTimeout(getData, 1);
                }
                cn.addEventListener('open', function(){
                    getData();
                });
            });
        </script>
    </body>
</html>

上記のとおり、ローカルホストで動作するWebSocketサーバとの通信を開き、getData関数を繰り返し呼び出します。