@aiquants/mssql
v0.1.3
Published
What SQL Server refuses, and how to stay inside it: retry a transaction only when it was the deadlock victim (keeping the cause the ORM destroys), read the SQL Server error number through wrapped causes, split statements to fit the 2100-parameter limit, a
Downloads
533
Readme
@aiquants/mssql
What SQL Server refuses, and how to stay inside it. Domain-free, dependency-free.
SQL Server が拒むものと、その内側に留まる方法。ドメインにも依存にも縛られません。
| 何を答えるか | 入口 |
| :--- | :--- |
| 失敗した作業単位を、もう一度実行してよいか | runWithTransactionRetry / isDeadlockVictim / isRolledBackByServer |
| 失敗が、SQL Server のどの番号の失敗か (包み直しの奥まで) | sqlErrorOf (SqlServerMessage) |
| 1 文に何件まで載せられるか | chunkByParameters / chunkSizeForParameters / chunk |
| 返ってきた日時は、どの瞬間か | toInstant |
いずれもドライバが押し付けてくる事実であり、呼び出し側のドメインには属しません。写しを各所に 置くと、片方だけ直る事故が起きます。
トランザクションの再実行
Runs a SQL Server transaction again when — and only when — the failure was a deadlock, and keeps the original cause the ORM would otherwise destroy.
SQL Server のトランザクションを、失敗がデッドロックであった場合に限ってやり直し、ORM が破壊して しまう元の原因を保持します。
Why this is not "retry on failure"
再試行が許されるには 2 つの条件が揃わねばならず、それらは別の問いです。
| 条件 | 問い | 揃わないと | | :--- | :--- | :--- | | 安全 | 何もコミットされていないか | 同じ変更を 2 度適用する | | 有効 | 失敗は一時的か | 同じ失敗を 3 倍遅く繰り返し、真の原因を埋める |
デッドロックの犠牲 (エラー番号 1205) は、その 1 つの事実で両方を満たします。犠牲は必ず巻き戻され、
かつ競合相手が去れば通ります。本パッケージが再実行の根拠として認めるのはそれだけです。
[!CAUTION] サーバが巻き戻したことは、一時的であることの証拠ではありません。 サーバは
XACT_ABORT ON下の 実行時エラーでも重大度 16 以上のエラーでも自分で巻き戻します。それらは何度やり直しても同じ結果に なります。EABORTを再実行の根拠にしてはなりません。
| 失敗 | 再実行 | 根拠 |
| :--- | :--- | :--- |
| sqlErrorOf(error, [1205]) !== null (デッドロックの犠牲: 番号 1205 と文面を持つ段) | する | 必ず巻き戻され、やり直せば通りうる |
| code === "EABORT" (サーバが巻き戻した) | しない | 巻き戻ったことしか示さない。原因が決定的なら永久に同じ |
| ETIMEOUT (要求の時限切れ) | しない | 文がサーバ側でまだ走っていることがある |
| 接続断 (ECONNCLOSED / ESOCKET) | しない | コミットの成否を持ち帰れない |
| number === 2627 / 2601 (一意制約違反) | しない | やり直しても同じ結果。そもそも一時的でない |
| number === 1222 (ロック待ちの時限切れ) | しない | XACT_ABORT が無い限り自動で巻き戻らない |
ORM は原因を破壊します — だから capture があります
drizzle-orm の node-mssql セッションはこう書かれています。
} catch (err) {
await mssqlTransaction.rollback(); // サーバが既に巻き戻していれば EABORT を投げる
throw err; // ← ここへ到達しない
}mssql の TransactionError は (message, code) しか受け取らず cause を持たないため、元の失敗
(1205 など) は痕跡なく消えます。届くのは「Transaction has been aborted.」だけです。
そこで本パッケージは閉包を包む capture を呼び出し元へ渡します。トランザクションの閉包の内側を
これで包んでください。
import { runWithTransactionRetry } from "@aiquants/mssql"
await runWithTransactionRetry(
(capture) =>
db.transaction((tx) =>
capture(async () => {
const current = await tx.select().from(rows).where(eq(rows.id, id))
if (current.length === 0) throw new Error("見つかりません")
await tx.update(rows).set({ value }).where(eq(rows.id, id))
}),
),
{ onRetry: ({ attempt, attempts, cause }) => log.warn(`deadlock victim; retrying ${attempt}/${attempts - 1}`, cause) },
)包み忘れても壊れません。原因を押さえられない以上やり直さないのが正しいため、再試行が効かなくなる だけで安全側に倒れます (書き込みが二重になることはありません)。
Install
pnpm add @aiquants/mssql依存はありません。mssql / tedious / ORM のいずれにも import を持たず、ドライバのエラーが
一様に載せる code・number・message と、原因の連なり (cause) だけを見ます (duck typing)。
Contract
| 項目 | 既定 | 備考 |
| :--- | :--- | :--- |
| attempts | 3 | 初回を含む。1 で再試行しない |
| delay(attempt) | 50ms から倍化・±25% のゆらぎ | 同時に犠牲となった要求が揃って再試行することを避けるため。試験では即時解決する関数を渡す |
| onRetry(info) | 何もしない | 黙って再試行しないこと。 再試行が成功すると利用者からは何も起きなかったように見えるが、競合が起きていること自体は運用上の事実である |
onRetry が受け取る info は、送出された失敗 (error、多くは EABORT) と押さえた原本 (cause) の
両方を運びます。記録すべきは cause です。
諦めるときは、原因の方を送出します
capture が原本を押さえており、送出された失敗が EABORT でそれを覆い隠している場合、本パッケージは
原本を送出します。「Transaction has been aborted.」だけを返すと、呼び出し元の応答に載るのがその
文字列になり、何が起きたのか誰にも分からなくなるためです。
判定だけを使う
import { isDeadlockVictim, isRolledBackByServer } from "@aiquants/mssql"isDeadlockVictim は「一時的か」、isRolledBackByServer は「巻き戻ったか」を答えます。別の問いで
あり、同じ失敗に同時には当たりません。 どちらを立てているのかを先に決めてください。
守るべき前提
workは毎回はじめから走ります。 部分的な再開はしません — 再試行が正しいのは「何も起き なかった」場合だけであり、途中から再開することはその前提を壊します。workは DB の外へ副作用を持ってはなりません。 トランザクションのロールバックで元に戻らない ため、これは再試行の有無に関わらず要求される性質です。- 既に外側のトランザクションに居るときは呼ばないでください。 再試行の判断はそれを開いた側が 持ちます。内側だけをやり直すと、外側が既に書いた分を巻き戻さずに重ねることになります。
- 1 文単位の送り直しには使えません。 それは「その 1 文は送られたか」という別の問いであり、判定の
根拠 (
ECONNCLOSED) も別です。接続基盤層に閉じて解決してください。
SQL Server のエラー番号を読む
一意違反 (2601 / 2627) や制約違反 (547) を応答へ写すとき、番号は ORM やアプリの包み直しで
cause の奥へ沈みます。走査を呼び出し側で写して持たないでください。深さを変えた日に「再実行される
失敗」と「番号で見分ける失敗」とで届く深さが黙って食い違います。isDeadlockVictim と同じ走査を
sqlErrorOf がそのまま公開しています。
import { type SqlServerMessage, sqlErrorOf } from "@aiquants/mssql"
const violation: SqlServerMessage | null = sqlErrorOf(error, [2601, 2627]) // { number, message }
if (violation === null) throw error| 規則 | 内容 |
| :--- | :--- |
| 辿る順と深さ | 失敗そのものから cause を浅い順に最大 5 段。循環する cause もそこで止まる |
| 返す段 | numbers のいずれかを持つ最も浅い段。numbers の並びは関係しない |
| SQL Server の失敗と認める段 | number が正の整数で message が文字列の段だけ。NaN・0・負数・文字列の番号や、文面の無い段は一致しない |
| 止まる所 | オブジェクトでない値 (null・undefined・関数・文字列など) に着いたら、それを段と認めず、その先も探さない |
| 戻り値 | 段そのもの (ドライバの Error) ではなく、番号と文面だけを写した SqlServerMessage ({ number, message })。どの段にも無ければ null |
isDeadlockVictim(error) は sqlErrorOf(error, [1205]) !== null そのものです。文面の無い段は、
1205 を持っていてもデッドロックの犠牲とは認めません。
[!IMPORTANT] サーバが既に巻き戻したトランザクションの失敗には、番号が残りません。 ORM がそれを原因の無い
EABORTへ置き換えるため (上の「ORM は原因を破壊します」)、db.transaction(...)の失敗へ直接当てるとnullが返ります。番号を読むなら、captureで包んだrunWithTransactionRetryが送出した失敗に当てて ください。諦めるときはEABORTではなく、押さえた原本が送出されます。
パラメータ上限
[!CAUTION] 上限は行数ではなくパラメータ数です。 1 行あたり何個の値を束ねるかは文ごとに違うので、行数だけで 分割すると、列の多い
VALUESが静かに上限を越えます。しかも越えるのは件数が閾値を跨いだ日だけで、 それまでは同じコードが問題なく動いて見えます (7 列 × 500 行 = 3500 で実行される前に拒否)。
| 定数 | 値 | 意味 |
| :--- | :--- | :--- |
| MAX_PARAMETERS | 2100 | SQL Server の RPC 1 回あたりの上限 |
| RESERVED_PARAMETERS | 100 | 文自身のスカラー値のための予約枠 |
| MAX_IN_LIST | 500 | 1 行 1 パラメータの IN 句 1 本の既定上限 |
import { chunkByParameters, chunkSizeForParameters, MAX_IN_LIST } from "@aiquants/mssql"
// 7 列の VALUES を 1 文に収まる単位へ分割する
for (const batch of chunkByParameters(rows, 7)) await db.insert(ledger).values(batch)
// 別の上限と併せたいときは、件数だけを取り出して小さい方を採る
const size = Math.min(MAX_IN_LIST, chunkSizeForParameters(1, RESERVED_PARAMETERS + FIXED_PARAMETERS))[!IMPORTANT] 予算を呼び出し側で組み直さないでください。
2100 - 100を2000と書くと、上限や予約枠が 変わってもそこだけ追従しません。引き算はchunkSizeForParametersに任せ、文ごとの事情はreservedParametersとして渡してください。
日時の読み取り
SQL Server の datetime2 は時間帯を持ちません。ドライバは通常 Date を組み立てて返しますが、設定や
経路によっては文字列のまま渡ってきて、その文字列にはオフセットが付きません。
import { toInstant } from "@aiquants/mssql"
const observedAt = toInstant(row.observedAt) // "2026-09-09 03:04:05.000" → 03:04:05 UTC[!CAUTION] オフセットの無い ISO 文字列は、仕様上「実行環境の時間帯」で解釈されます。 JST で走らせれば 9 時間ずれます — しかも落ちません。鮮度は「常に古い」と報告され、締切の比較は静かに 9 時間ぶん 甘くなります。読む場所ごとに対処すると必ずどこかが漏れるので、1 つの関数に閉じてください。
読めない値は例外ではなく Invalid Date として返します。ここで投げると 1 行の壊れた値が一覧全体を
落とすためで、判定は呼び出し側が Number.isNaN(d.getTime()) で行います。
License
MIT
