Skip to content

Feature: Support SQLite file sessions for persistent update state (fixes update flood on restart) #169

Description

@alex3alex

Feature: Support SQLite file sessions for persistent update state

Problem

When using TELEGRAM_SESSION_STRING (StringSession), Telethon has no persistent update state (pts/qts/date/seq). Every time the MCP server restarts, Telegram floods the client with all accumulated updates across all chats/channels since the last online time.

For accounts with many channel subscriptions, this means thousands of updates.getDifference requests on every reconnect — which:

  1. Blocks the asyncio event loop processing the flood
  2. Causes MCP clients (e.g. mcporter) to timeout waiting for responses
  3. Triggers process spawning cascades (client thinks server is dead → spawns new one → old one still processing flood → new one also gets flooded)
  4. Can lead to OOM kills on memory-constrained servers

PR #140 (v3.1.17) fixed the blocking issue by moving entity cache warming to background. But the root cause — StringSession not persisting update state — remains.

How Telegram update state works

Telegram tracks a cursor (pts, qts, date, seq) for each client. On reconnect:

  • With persisted state: client sends updates.getState → Telegram returns only missed delta (a few updates)
  • Without persisted state (StringSession): Telegram treats it as a fresh client → dumps all accumulated updates

Official Telegram apps (phone, desktop) use local SQLite databases to persist this state.

Solution

Telethon already supports SQLite file sessions via TelegramClient(session_name, api_id, api_hash) where session_name is a string (not a StringSession object). This creates a .session SQLite file that stores:

  • ✅ Auth key (same as StringSession)
  • Update state (pts/qts/date/seq) — the missing piece
  • ✅ Entity cache (access_hash for users/chats/channels)
  • ✅ Sent files cache

Current code (runtime.py)

session_string = os.getenv("TELEGRAM_SESSION_STRING")
session_name = os.getenv("TELEGRAM_SESSION_NAME")

if session_pool:
    accounts["default"] = _build_client(
        StringSession(_acquire_session(session_pool)), "default"
    )
elif session_string:
    accounts["default"] = _build_client(StringSession(session_string), "default")
elif session_name:
    accounts["default"] = _build_client(session_name, "default")

TELEGRAM_SESSION_NAME already creates a file-based session! But it is buried under TELEGRAM_SESSION_STRING in priority, and there is no migration path or documentation warning about the update flood issue.

Important gotcha: because runtime.py calls load_dotenv(), if both TELEGRAM_SESSION_STRING and TELEGRAM_SESSION_NAME are present in .env, StringSession wins. Users must explicitly comment out / remove TELEGRAM_SESSION_STRING for the file-based session to take effect.

Proposed changes

1. Migration script (migrate_session.py)

A script that converts an existing StringSession to a SQLite file session:

#!/usr/bin/env python3
"""Migrate StringSession to SQLite file session for persistent update state."""
import asyncio, os, sys
from dotenv import load_dotenv
from telethon import TelegramClient
from telethon.sessions import StringSession

load_dotenv()

API_ID = int(os.getenv("TELEGRAM_API_ID"))
API_HASH = os.getenv("TELEGRAM_API_HASH")
SESSION_STRING = os.getenv("TELEGRAM_SESSION_STRING")
TARGET = os.getenv("TELEGRAM_SESSION_FILE", "telegram_mcp_session")


async def migrate():
    if not SESSION_STRING:
        print("Error: TELEGRAM_SESSION_STRING not set", file=sys.stderr)
        sys.exit(1)

    old = TelegramClient(StringSession(SESSION_STRING), API_ID, API_HASH)
    await old.connect()
    if not await old.is_user_authorized():
        print("Error: session not authorized", file=sys.stderr)
        sys.exit(1)

    dc_id = old.session.dc_id
    server_address = old.session.server_address
    port = old.session.port
    auth_key = old.session.auth_key
    await old.disconnect()

    new = TelegramClient(TARGET, API_ID, API_HASH)
    await new.connect()
    new.session.set_dc(dc_id, server_address, port)
    new.session.auth_key = auth_key
    new.session.save()

    if await new.is_user_authorized():
        me = await new.get_me()
        print(f"Migration successful: {me.first_name} (id={me.id})")
    else:
        print("Migration failed", file=sys.stderr)
        sys.exit(1)

    await new.disconnect()
    print(f"File {TARGET}.session created.")
    print(f"Set TELEGRAM_SESSION_NAME={TARGET} in .env")
    print(f"Comment out TELEGRAM_SESSION_STRING in .env")


asyncio.run(migrate())

2. Documentation

Add a section to README recommending file-based sessions for long-running servers:

## Recommended: File-based session for servers

StringSession (`TELEGRAM_SESSION_STRING`) does not persist Telegram update state
(pts/qts/date/seq). Every server restart triggers a full update flood from Telegram
across all chats and channels — potentially thousands of requests.

For long-running servers, use a file-based session:

1. Migrate your existing session:
   ```bash
   uv run migrate_session.py
  1. In .env, comment out StringSession and add file-based session:
    # TELEGRAM_SESSION_STRING=...
    TELEGRAM_SESSION_NAME=telegram_mcp_session

The .session SQLite file stores update state locally. On reconnect, Telegram sends
only the missed delta instead of flooding.


### 3. Optional: Warning when using StringSession

Print a warning when `TELEGRAM_SESSION_STRING` is used without a file-based session:

```python
elif session_string:
    print(
        "Warning: TELEGRAM_SESSION_STRING (StringSession) does not persist "
        "update state. For long-running servers, consider migrating to a "
        "file-based session (TELEGRAM_SESSION_NAME). See: migrate_session.py",
        file=sys.stderr
    )
    accounts["default"] = _build_client(StringSession(session_string), "default")

Verification

After migration, the SQLite session file contains:

Tables: version, sessions, entities, sent_files, update_state

update_state: (id=0, pts=150548, qts=0, date=1785190768, seq=76796)
entities cached: growing as dialogs are fetched

On restart, pts is preserved → Telegram returns only delta updates → no flood.

Tested with:

  • mcporter list → telegram online, 116 tools
  • Process count stays at 2 (uv launcher + python), no spawn cascade
  • Zero Got difference for channel entries in logs after restart
  • send_message and get_messages work correctly

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions