背景
Discord や X(旧 Twitter)の ID から「これはいつ作られたのか」を出すツールを置いています。
中身は数行のビット演算なのですが、作るときに Number と BigInt の違いでちょっとハマりかけました。
しかも壊れ方がいやらしくて、パッと見は正しく動いているように見える…
その確認をもう一度手元でやったので、書いておきます。
Snowflake ID の中身
Snowflake ID は、Twitter が 2010 年ごろに公開した ID の作り方だ。
64 ビットの整数を区切って、上位に「基準時刻からの経過ミリ秒」、下位に「どの機械の何番目か」を詰める。
時刻が上位にあるので、ID を並べるとそのまま作成順になる、というのが売りです。
Discord の公式ドキュメント( https://docs.discord.com/developers/reference )の Snowflakes の節に、区切り方が書かれています。
それを書き写したのが下記です。
63〜22 ビット タイムスタンプ(Discord エポックからのミリ秒・42 ビット)
21〜17 ビット 内部ワーカー ID(5 ビット)
16〜12 ビット 内部プロセス ID(5 ビット)
11〜0 ビット インクリメント(12 ビット)
エポックは、その時刻を「0」として数え始める基準時刻のこと。
Discord は 1420070400000(2015-01-01 00:00:00 UTC のミリ秒)です。
Twitter の元実装は 1288834974657 を使っています(下位 22 ビットの分け方は同じで、名前が datacenter / worker / sequence になっている)。
分解する関数
下記が、ID の文字列とサービス名を受け取って、作成時刻と下位の内訳を返す関数です。
ツールの decode() から画面まわりを外して、同じ計算だけを残したものになります。
Node.js v24.11.1 で動かしました。
// Snowflake ID から作成時刻を取り出す
var EPOCHS = { discord: 1420070400000n, twitter: 1288834974657n };
function decodeSnowflake(idStr, service) {
var id = BigInt(idStr); // Number にすると下の桁が壊れる
var ms = (id >> 22n) + EPOCHS[service]; // 上位42ビットがエポックからのミリ秒
return {
time: new Date(Number(ms)).toISOString(),
worker: Number((id >> 17n) & 0x1Fn),
process: Number((id >> 12n) & 0x1Fn),
increment: Number(id & 0xFFFn)
};
}
var id = '175928847299117063';
console.log('discord', decodeSnowflake(id, 'discord'));
console.log('twitter', decodeSnowflake(id, 'twitter'));
// JSON で数値のまま受け取ると
var body = '{"id": 175928847299117063}';
console.log('JSON.parse ->', JSON.parse(body).id);
console.log('MAX_SAFE_INTEGER ->', Number.MAX_SAFE_INTEGER);
console.log('decode(壊れた値) ->', decodeSnowflake(String(JSON.parse(body).id), 'discord'));
175928847299117063 は Discord のドキュメントに載っている例の ID です。
右シフトで上位だけ残してエポックを足すと、Unix 時刻のミリ秒になる。
& 0x1Fn は下位5ビットだけを取り出すマスクです。
実行結果
上のファイルを sf.js として保存し、実行した出力が下記です。
$ node sf.js
discord {
time: '2016-04-30T11:18:25.796Z',
worker: 1,
process: 0,
increment: 7
}
twitter {
time: '2012-03-03T13:01:20.453Z',
worker: 1,
process: 0,
increment: 7
}
JSON.parse -> 175928847299117060
MAX_SAFE_INTEGER -> 9007199254740991
decode(壊れた値) -> {
time: '2016-04-30T11:18:25.796Z',
worker: 1,
process: 0,
increment: 4
}
Discord として読むと 2016-04-30 11:18:25.796 UTC で、ドキュメントの記載と一致しました。
同じ数字を Twitter のエポックで読むと 2012 年になります。
ID だけ見てもどのサービスのものかは分からないので、ツールではサービスを選ばせています。
問題は下の3行です。
JSON.parse で数値として受けると、末尾が 063 から 060 に変わっています。
JavaScript の Number は倍精度浮動小数点数で、整数を正確に持てるのは 2^53 - 1(9007199254740991)までだ。
Snowflake ID はとっくにそれを超えているので、下位の桁が丸められる。
いやらしいのは、壊れた値を分解しても時刻は同じ 11:18:25.796 のままなことです。
丸めで消えるのは下位の数ビットだけなので、時刻の入っている上位は無事なんですね。
変わったのはインクリメントの 7 → 4 だけで、画面で時刻を見ている限りまず気づけません。
でも ID としては別物なので、その値で API を叩くと存在しない相手を指すことになります。
だから文字列で受けて BigInt で割る
Discord のドキュメントにも、64 ビットの ID は言語によって溢れるので HTTP API では常に文字列で返す、と書かれています。
受け取る側も、文字列のまま持って、計算するときだけ BigInt にするのが安全です。
ツールでも入力欄の文字列をそのまま BigInt() に渡していて、途中で一度も Number を通していません。
最後に Number(ms) へ戻しているのは、ミリ秒の値が 2^53 よりずっと小さく、Date に渡すには Number が要るからです。
64 ビットの ID を扱うときは、時刻が合っていることを正しさの証拠にしないでください。
本記事はAI補助で執筆した、個人開発の紹介記事です。