0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Snowflake ID を JSON の数値で受けると、時刻は合っているのに ID が別物になる

0
Posted at

背景

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補助で執筆した、個人開発の紹介記事です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?