ADR-015: One bot process — long polling, worker and schedulers together, guarded by a lock
Who is this for
Section titled “Who is this for”Developers — read this before splitting the worker out, adding webhooks or running a second bot instance.
main.pyruns polling, the queue worker, the delivery sweeper, the TTL cleanup and the leads loop as tasks of one asyncio program.- Telegram updates arrive by long polling, not webhooks.
- A lock file prevents a second instance. Shutdown is ordered and returns unfinished jobs to the queue.
- Status: accepted for one station.
Context
Section titled “Context”Two bot processes with the same token fight over updates, and two workers with different configuration take jobs from the same queue. This happened during development: an old process started before a configuration change picked up a job and rendered it in the wrong mode. Separately, the process used to hang on stop until it was killed, because only polling stopped while the other loops kept running.
Decision
Section titled “Decision”- One process, several tasks. If any task ends or crashes, the process shuts down with a non-zero code and
systemd restarts it (
bot/main.py,serve). - Long polling. The bot needs no public endpoint; the API process sends only one-off messages with the same token and never polls.
- Instance lock.
data/main.lockis taken at start; a second process logs that one is already running and exits (bot/instance_lock.py). - Ordered shutdown on
SIGINT/SIGTERM: stop polling → take no new jobs → give active jobs 15 seconds → return the rest to the queue without spending an attempt → cancel the loops → cancel delivery checks (jobs staydeliveringand are picked up after restart) → close the bot session. systemd waits 30 seconds. - Blocking work off the loop. AI requests run in daemon threads; image processing runs in worker threads with its own concurrency limit.
Alternatives considered
Section titled “Alternatives considered”| Alternative | Why not (now) |
|---|---|
| Webhooks | Need a public route and secret handling; polling is enough for the message volume of an event. |
| Worker as a separate service | Cleaner scaling, but one more unit, and SQLite keeps everything on one machine anyway. |
| Several bot instances | Impossible with polling on one token. |
Consequences
Section titled “Consequences”- A crash in any loop restarts everything; loops that must not die catch their own errors (cleanup, leads).
- Image processing shares memory and CPU with the bot; the unit’s memory limits are sized for the pack peak.
- Before starting a local bot with the production token, the server’s service must be stopped — and the other way round. The event checklist includes this.
- Scaling out means separating the worker and replacing polling or keeping exactly one poller — see Scaling scenarios.
Evidence
Section titled “Evidence”HANDOFF.md, “Known pitfalls” (“Only one main.py”) and the shutdown paragraph.tests/test_shutdown.py; commits7e70346,9e5067e.
Where to go next
Section titled “Where to go next”Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.