# BROADCAST.md — Scale-Safe Broadcast Engine Architecture

This document describes the architecture of the Update Pack broadcast
("پیام‌رسانی همگانی") engine (F14), built to safely reach tens of
thousands of Telegram users from a shared-hosting PHP process without
hanging a webhook request, hitting Telegram's rate limits, or leaving
the bot in an inconsistent state if the process dies mid-send.

## Why not "just loop and send"?

A naive `foreach ($users as $u) { sendMessage(...) }` inside a single
webhook request is unacceptable on shared hosting for several
independent reasons, all of which this design solves:

1. **PHP execution time limits.** Shared hosts commonly cap a single
   PHP-FPM/CGI request to 30–60 seconds. Sending to 40,000 users at
   even 20 messages/second would take ~33 minutes — the process would
   be killed long before finishing, with no way to know how far it got.
2. **Telegram rate limits.** Telegram enforces roughly 30
   messages/second bot-wide (with per-chat bursts even more
   restricted). A tight loop will hit `429 Too Many Requests`
   immediately at scale.
3. **No resumability.** If the process dies at message 12,000 of
   40,000, a naive design has no record of who was already sent to —
   a restart would either re-send to everyone (spam/rate-limit-storm)
   or silently give up.
4. **Blocked users.** A user who has blocked the bot returns `403` on
   every future send attempt — without tracking this, the same 403 is
   retried forever, wasting API quota.

## Architecture

### Tables

- **`broadcast_jobs`** — one row per broadcast: audience filter,
  message text, optional button, status (`draft` → `running` →
  `completed`/`cancelled`/`failed`), batch size, running counters
  (`sent_count`/`failed_count`/`skipped_count`), a `lease_token` +
  `lease_expires_at` pair (concurrency guard, see below), and a
  `progress_message_chat_id`/`progress_message_id` pointer for the
  live-editing progress display.
- **`broadcast_job_items`** — one row per (job, user) target. Status
  starts `pending`, ends in `sent`/`failed`/`skipped`. `UNIQUE(job_id,
  user_id)` prevents duplicate targeting even if the population step
  runs twice.

### Population (audience selection)

`BroadcastRepository::populateTargetsAndStart()` uses a single
`INSERT INTO broadcast_job_items (...) SELECT ... FROM users WHERE
...` — **never** a PHP loop over users. The `non_members` audience
filter reads `users.main_channel_status` (a column kept accurate by
the real `chat_member` webhook event handling, see
`docs/DOMAIN.md` §Membership) — it is **never** a live
`getChatMember` call per user, which would itself be a Telegram-API-
call storm on a 40k-user list.

### Sending: `BroadcastService::pump()`

`pump($jobId, $maxSeconds = 3)` processes **one bounded batch** of a
running job and returns immediately:

```
do {
    if job cancelled -> stop
    batch = claim up to `batch_size` (default 25, admin-configurable
            via the `broadcast_batch_size` setting) pending items
    if batch empty -> stop (job finished)
    for each item in batch:
        send message (respects Telegram 429/permanent-error handling
        via the shared TelegramClient — see below)
        mark item sent / failed / skipped
} while (elapsed < $maxSeconds)
```

A single `pump()` call therefore sends **at most a few dozen
messages** and returns well within any shared-hosting time limit. The
admin-facing controller (`AdminBroadcastController::confirmAndStart()`)
calls `pump()` a small fixed number of times immediately (covers
small/medium audiences completely within one request), then reports
"باقی‌مانده: N — دکمه ادامه را بزن" for anything left over.

### Continuation for large audiences

Two supported ways to drain a large remaining queue, both calling the
exact same `pump()` method:

1. **Manual**: the admin taps "▶️ ادامه ارسال" (`w.continue`
   callback) as many times as needed — each tap is another short,
   bounded `pump()` call.
2. **Automatic (recommended for very large audiences)**: a cPanel cron
   job hits a small authenticated internal endpoint once a minute that
   calls `pump()` on every `status='running'` job. See
   `docs/TELEGRAM_LIMITS_AND_SCALE_FA.md` for the exact cron
   recommendation and endpoint pattern.

### Concurrency safety: the lease

Two `pump()` invocations could theoretically race (an admin tap and a
cron tick landing in the same second). `BroadcastRepository::
acquireLease()` does an atomic `UPDATE ... WHERE lease_token IS NULL
OR lease_expires_at < NOW() OR lease_token = :same_token` — only ONE
caller can ever hold the lease for a job at a time (55-second TTL, so
a crashed pump's lease self-expires rather than deadlocking the job
forever). A lease-miss makes `pump()` a safe no-op that just reports
the job's current counters.

### Telegram-safety inside `sendOne()`

- `disable_web_page_preview: true` always set.
- On `ok=false, error_code=403` ("bot was blocked by the user"): the
  user is marked `is_blocked=1` immediately (so they drop out of every
  future job's `all` audience automatically) and the item is marked
  `skipped` — never retried.
- Any other failure is marked `failed` with the Telegram description
  stored (truncated) for admin visibility, but **never retried
  in-loop** — Telegram-level retry/backoff for transient errors
  (429/5xx) is handled once, centrally, inside `TelegramClient` itself
  (see `docs/TELEGRAM_LIMITS_AND_SCALE_FA.md`), not duplicated here.

### Progress display (throttled, never per-message)

`maybeEditProgressMessage()` edits the SAME admin-chat message
(`progress_message_id`) every `broadcast_progress_every` processed
items (default 5, admin-configurable) — never once per recipient,
which would double the API call volume of the broadcast itself for no
benefit. A failed edit (e.g. "message is not modified", or the admin
deleted the message) is swallowed and never interrupts sending.

### Cancel

`w.cancel_job` sets `status='cancelled'` immediately. The next
`pump()` call for that job checks `isJobCancelled()` at the top of its
loop and exits without processing further items — already-claimed
in-flight sends from a currently-running pump are allowed to finish
(never left in an ambiguous "half sent" per-item state), but no new
batch is claimed.

## Settings

| Key | Default | Meaning |
|---|---|---|
| `broadcast_batch_size` | 25 | Items processed before checking the time budget again |
| `broadcast_progress_every` | 5 | How many processed items between progress-message edits |
| `telegram_max_retries` | 2 | Shared with all Telegram API calls, see `TelegramClient` |

## What this design deliberately does NOT do

- It does not guarantee sub-second delivery for large audiences — a
  40,000-user broadcast is expected to take from several minutes to
  an hour depending on cron frequency/admin patience, by design.
- It does not implement a full job-priority queue — one broadcast job
  runs at a time per admin action; if you need to queue multiple
  broadcasts, start the next one after the previous completes.
- It does not retain full per-recipient message history forever —
  `broadcast_job_items` rows are the audit trail (status + truncated
  error text only), never the message body per recipient.
