Caching¶
What is cached¶
Key (prefix tme:v2:) |
Value | TTL |
|---|---|---|
json:body:page:{mode}:{channel}:{position} |
Rendered /v1/body response |
page TTL (CACHE_MODE: 0 / 10 / 120 s) |
page:{mode}:{channel}:{position} |
Parsed page (raw dict as JSON) | page TTL; for the latest page with the feed enabled: max(page TTL, FEED_STALE_TTL) |
more:{mode}:{channel}:{direction}:{position} |
Rendered /v1/more response |
page TTL |
preview:{channel} |
Parsed preview, or null for unknown channels |
PREVIEW_CACHE_TTL (1 h), 60 s for unknown |
feed:popularity |
Sorted set channel → score | — (decayed by the refresher) |
feed:refresher-leader |
Token of the leading refresher | 30 s, renewed every 5 s |
{mode} is the parser mode, so full and simplified instances can share a
Redis without mixing results. Channel names are lower-cased.
Every value is stored together with the time it was written
("<unix time>\n<payload>" in Redis), which lets callers apply their own
freshness rules on top of the backend expiry — the feed's
stale-while-revalidate does exactly that.
Levels¶
Every worker has an in-process cache. With REDIS_URL set it is the first
level (L1) in front of Redis / Valkey (L2), which every worker and server
shares; without Redis it is the only level. Services see one Cache either
way (TwoLevelCache or MemoryCache in cache.py).
| Request repeated on the same worker | 2.0.0 | Now (L1 hit) |
|---|---|---|
/v1/body, Redis 90 ms away (Upstash) |
95 ms | 0.10 ms |
/v1/body, Redis on the same host |
0.35 ms | 0.10 ms |
/v1/body, no Redis |
0.15 ms | 0.10 ms |
See Performance for every endpoint.
In-process level¶
telegram_me_native.LocalCache: Rust, on moka's concurrent cache.
- LRU, bounded by real memory. An entry weighs its key, its stored value
and 512 bytes of bookkeeping (moka's entry, hash-table, LRU and timer nodes:
~390 bytes requested, measured with a counting allocator, plus malloc's
rounding). The total stays within
MEMORY_CACHE_MAX_MBright after every write;rust/telegram-me-native/tests/cache_memory.rschecks that what is accounted covers what is really allocated. Every new entry is admitted (no TinyLFU admission filter): a page just fetched is about to be read again. - Compressed values. zstd level 1 (libzstd, C): a rendered page takes
12–43 KiB instead of 44–233 KiB of UTF-8, and reading one back
(decompressing straight into a new
bytes) takes ~40 µs for a 95 KB page. Values that do not shrink are stored as they are. - Per-entry expiry through moka's
Expiry: each entry lives for its own TTL. Expired entries are never returned and are removed by moka's timer wheel within about a second, read or not. - The GIL is kept. Reads and writes take tens of microseconds and run on the event loop. Released, the GIL goes to a parsing thread busy in pydantic, and the event loop waits for it to come back: with two CPU-bound Python threads running, a read took 6.3 ms (p50) instead of 38 µs.
Redis level¶
Commands time out after REDIS_TIMEOUT (0.5 s). Three failures in a row
within 10 seconds open a circuit breaker: Redis is then skipped for 30
seconds, so an outage costs a few slow requests per worker, not one per
request. A single failure, such as a slow TLS connect to a distant Redis or a
lost packet, does not open it, and any command that reaches Redis resets the
count. Responses are parsed by hiredis (C).
A feed or previews request runs up to 100 cache lookups at once, so the
connection pool (2 × TELEGRAM_MAX_CONCURRENCY per worker, at least 32) makes
a burst wait up to REDIS_TIMEOUT for a free connection. A command that gets
none is skipped as a miss; an exhausted pool is a local limit, so it does not
open the circuit breaker.
How the levels work together¶
- Read: L1 first. On a miss, one round trip to Redis (a pipeline of
GETandPTTL) returns the entry and its remaining TTL; L1 keeps a copy for that remaining TTL, at mostMEMORY_CACHE_MAX_TTL(10 s). Concurrent misses of one key share a single Redis read. A copy never replaces a newer entry that was written to L1 while the read was in flight. - Write: to both levels, with the same store time. L1 keeps the entry for
min(TTL, MEMORY_CACHE_MAX_TTL). - Store time: a copy keeps the time the entry was first stored, so an entry has the same age on every server and the feed's stale-while-revalidate works unchanged.
- Redis down or circuit breaker open: L1 works alone. Reads that miss L1 are misses; writes go to L1 only, for their full TTL, since there is no shared copy to pick up instead.
Why MEMORY_CACHE_MAX_TTL. Two servers (and the feed worker) share Redis
but not their L1s, and nothing tells a server that another one has written a
newer entry. For most keys the staleness is bounded by their TTL anyway, which
is acceptable: a rendered page lives for the page TTL on every level. The
latest page of a channel, however, is kept for the feed's stale window
(FEED_STALE_TTL, 15 min) while it counts as fresh only for the page TTL
(10 s) or FEED_FRESH_TTL (60 s). Without a cap, a server would keep its old
copy for up to 15 minutes and re-fetch the page from t.me when it got old,
instead of reading the copy the refresher (or the other server) had just put
in Redis; with the feed worker, the API servers would ignore its work. With
the cap, a server sees a newer entry in Redis at most 10 seconds after it was
written. The cost is one Redis read per key and worker every 10 seconds at
most, for keys that are being read.
Values are UTF-8 bytes on both levels. Keeping JSON as str cost up to four
bytes per character (a single emoji widens the whole string) and an encode on
every cache hit.
Pre-rendered responses¶
For /v1/body and /v1/more the cached value is the final JSON. When a page
is loaded it is parsed, validated and serialized once; every cache hit is
served as bytes. This makes cache hits about 5× cheaper than re-validating the
parsed page on each request (see Performance).
Request coalescing¶
Identical concurrent requests do the work once: one request to t.me and one
render, wherever they arrive. The pieces below are measured in
Performance; the scheduling core (flight
table, upstream gate, renewal points) is Rust in
sched.rs,
the Python services only wire it up.
In one worker¶
SingleFlight keys in-flight work by cache key, separately for parsed pages,
rendered bodies, rendered posts, /v1/more fragments and previews. While t.me
is being asked for a page, every other request for it waits for that one task
instead of sending its own — a burst of 1000 requests for a cold channel
produces one upstream request. A client that disconnects does not cancel the
shared task. Because REST and GraphQL resolve through the same services, a
GET /v1/body/durov and a GraphQL body(channel: "durov") share the fetch
and the render too; /v1/post/durov/531 and /v1/body/durov?position=531
share the page around post 531; a feed and a single-channel request share the
channel's latest page; identical feed requests are built, ranked and rendered
once.
Keys are normalized before they are compared: channel names are
case-insensitive (Durov and durov are one key, for feeds as well) and an
absent position is position 0. Pages around different positions are not
treated as interchangeable even when they contain the same post: every page
load carries its own per-post view tokens (and t.me renders footers and
forwards slightly differently around the anchor), so serving one for the
other would change responses.
Across workers and servers¶
Every granian worker of every server has its own single flights, so on their
own a hot key that expires is fetched once per worker per server — 6 times
with two servers of three workers. With Redis, the worker that leads locally
first takes a lease, tme:v2:lease:<key>, in one round trip that also reads
the cached value (a Lua script: SET NX PX plus GET):
- the cached value is already fresh (another process just wrote it): use it and keep a copy in the in-process cache, no fetch;
- the lease is taken: fetch, write the cache, release the lease;
- another process holds the lease: poll with jittered back-off (25 ms growing to 250 ms), retrying the lease on every round, until the holder's result is in the cache. A holder that failed with a domain error (not found, parse, upstream) leaves that error in the lease for 2 s, so waiters — and requests arriving meanwhile — fail the same way instead of all retrying t.me.
The lease lives 3 s and its holder extends it every second while the fetch
runs, so a holder that crashed or lost Redis is taken over within 3 s, however
long t.me takes for a live one. No waiter waits longer than
2 × TELEGRAM_TIMEOUT + 3 seconds in all, and none waits for Redis longer
than REDIS_TIMEOUT per command: when Redis is slow, down or its circuit
breaker is open, a worker simply fetches on its own. The local single flight
still applies then, so an outage costs one fetch per worker (the behaviour
before the lease), never a thundering herd. SHARED_COALESCING=false turns
the lease off. Without Redis there is nothing to coordinate through, and each
worker coalesces on its own.
The feed refresher goes through the same path: a refresh that a user request on another server is already running joins it, and a page that some process fetched within the last half refresh interval is not fetched again.
Early renewal¶
Each entry gets a renewal point in the last EARLY_REFRESH_WINDOW (10 %) of
its freshness, derived from the time it was stored, so that every reader in
every process agrees on it and entries stored together (a refresher tick)
renew spread out. The first request that reads a page, rendered body,
rendered post or /v1/more entry past that point is still answered from the
cache and starts a background refresh; later reads of the same entry in that
process do not ask again, and the refresh is coalesced like any fetch. A hot
key is therefore fetched about once per TTL and never expires under load —
no miss storm, and nobody waits for t.me. A probabilistic scheme such as
XFetch was tried and rejected: with hundreds of reads per second it renews
about ln(reads per window) windows before expiry, doubling the upstream
traffic of exactly the hottest keys. Long TTLs (previews, the feed's stale
window) are also shortened by a random part of the window, so entries
written together do not expire together.
Upstream protection¶
- Fair concurrency cap. Each worker sends at most
TELEGRAM_MAX_CONCURRENCY(32) requests to t.me at once. Free slots go round-robin over flows — a channel, or one feed or previews request as a whole — rather than first come, first served, so a 100-channel feed does not make a single-channel request wait behind it. One flow holds at most three quarters of the slots. Background refreshes hold at most half and only get a slot while no user request waits; a user who joins a refresh still waiting for a slot raises it to user priority. - Bounded queues, load shedding. At most
UPSTREAM_QUEUE_LIMITrequests (default 16 per slot) wait for a slot, and at mostMAX_WAITING_REQUESTS(4096) requests wait for upstream work of any kind per worker. Beyond that a request is answered503withRetry-After(estimated from the queue length and how long a slot is held) instead of queueing without limit; a feed or previews request skips the channels that were shed. Background work beyond the bounds is dropped silently. - Unknown channels are cached as
nullfor 60 s. - Cloudflare edge caching is enabled through
Cache-Control(see Reverse proxy).