Notes

The 64-bit bug that broke my Telegram bot

How a Telegram user ID ended up stored as 6123456789.0, and why it only affected some people.

In February I was reworking the lounge booking Mini App for my residential college, which about 130 residents use now. Some residents, about 40% of them, couldn't see their own bookings.

When a bug only hits some users, the first thing to work out is what those users have in common. Here it was the size of their Telegram ID. (I'll use 6123456789 as the example throughout. It isn't a real person's ID.)

How it happened

Telegram IDs can be big

Telegram's docs say a user ID can need more than 32 bits, though never more than 52, so a 64-bit integer or a double can always hold one. Plenty of real IDs are bigger than 2,147,483,647, the largest number a signed 32-bit integer can hold.

JavaScript handles them fine

A JavaScript number is a 64-bit float, which stores whole numbers exactly up to 253. In Node, 6123456789 is still exactly 6123456789.

The SQLite driver treats them differently

When node-sqlite3 passes a JavaScript number into a query, it checks whether the number fits in a 32-bit integer. If it does, it's sent as an integer. If it doesn't, it's sent as a floating-point number. The logic is in src/statement.cc.

// node-sqlite3, simplified from src/statement.cc
if (fitsInInt32(value)) bindAsInteger(value)   // 123456789
else                    bindAsFloat(value)     // 6123456789.0

So small IDs reached SQLite as integers and big ones reached it as floats.

The column was text

The telegram_id column was declared as text, which was the right idea. But SQLite converts whatever it gets into the column's type, and a float with nothing after the decimal point becomes text ending in ".0". You can reproduce it in a few lines of Python:

db.execute("CREATE TABLE users (telegram_id TEXT UNIQUE)")
db.execute("INSERT INTO users VALUES (?)", (123456789,))     # integer
db.execute("INSERT INTO users VALUES (?)", (6123456789.0,))  # float
db.execute("SELECT telegram_id FROM users").fetchall()
# [('123456789',), ('6123456789.0',)]

The lookup came from the URL

The "my bookings" endpoint read the ID from the URL, /api/bookings/user/6123456789. Anything from a URL is a string, so the query compared "6123456789" with the stored "6123456789.0", and they never matched. The bookings were in the database. The app just couldn't find them.

For anyone whose ID fit in 32 bits, every step went through cleanly. For everyone else, it broke. That was the 40%.

The fix

The fix I shipped turns the ID into a plain string everywhere it enters the backend: when a user is created, when bookings are looked up and when one is cancelled.

const cleanId = String(telegramUser.id).split('.')[0];

It works, but it's a fix at every entry point. If I were starting again, I'd turn the ID into a string once, as soon as it arrives from Telegram, and never let it be a number anywhere in the code.

What I took from it

You never do maths on an ID, so it shouldn't be a number. The same goes for phone numbers, postal codes and student numbers.

The value went through Telegram's JSON, JavaScript, the driver and SQLite, then came back out through a URL. Each of those steps was reasonable on its own, and the bug was in how they fit together. Now I check what type a value is after it crosses one of those boundaries.

Whether you ever see this bug depends on whose accounts you test with, which is a good reason to test with data that looks like your real users' and not just your own.

The rest of the project, including a rate limiter that got confused by a proxy, is in the Lounge Booking write-up.