Skip to content

Live Updates (The Radio With Numbered Messages)

The Radio in the Dining Hall

During lunch the head teacher reads out news over a radio: "Lesson 3B has moved to the lab", "The bus is late". Each item is numbered. Suppose your radio crackles out for a minute and comes back at item 41. You say "I last heard number 38", and a helper reads out 39, 40 and 41 from a clipboard. If you were gone so long that the clipboard no longer reaches back to 38, nobody can fill the gap, so the helper says one thing only: start again. You walk back in and read the noticeboard afresh.

That is how the board stays up to date without you pressing refresh.

Server-Sent Events

The browser opens one long-lived request to /events, and the server sends a line of news whenever something happens (this is called Server-Sent Events, SSE). The news has a type:

  • run: a run changed state (run_id, job, run_date, state);
  • attempt: an attempt started or ended;
  • reload: a config reload put a new version in force.

The page does not build rows from this news. It asks the server for the new HTML of just that row (/ui/fragments/board-row/<run_id>) and swaps it in, so all drawing lives in one place, in Python. A reload redraws the whole board (jobs, tags and criticality may have changed), and a board opened without ?date= moves on to the new day by itself once the first run of that day is heard after midnight.

Why Ids and Replay

Pyweb's EventHub just shouts; it does not number anything and it cannot replay. A browser that loses its connection (a laptop lid, a bad wifi) would silently miss changes and show a board that lies. So ui/live.py adds:

  • a rising id on every event, like a1b2c3d4-41: the first part names this run of the scheduler (its boot), the second counts up;
  • a ring buffer of the last 1000 events (LIVE_BUFFER);
  • replay: a reconnecting browser sends Last-Event-ID, and gets every buffered event after it;
  • reset: if the id is from another boot (the scheduler restarted), is garbage, or fell off the buffer, the server sends a single reset event and the page reloads itself.

A page's very first connection cannot send Last-Event-ID, so each page carries the newest id at the moment it was drawn and connects to /events?after=<id>; news that came out while the page was on its way is replayed too.

Events are published only after the change is safely in the store, so a page never hears about something a crash could take back.

When the Radio Goes Quiet for Good

A browser reconnects by itself when the line drops. But if the server answers a reconnect with anything other than the stream (most often 401, because the 8-hour session has run out or the user was removed), the browser gives up for good. A board that silently stopped changing would lie, so the page says "Live updates stopped — reloading" and reloads itself. A reload is an ordinary page load: an expired session lands on the login page, which brings you back afterwards. The same happens when a row or counts fetch, an action button or the log viewer is answered 401: the page says "Your session has expired — please sign in again" and reloads.

When a reload swaps the users file or the tokens file, the server ends every open stream itself. Everyone's browser reconnects straight away with Last-Event-ID, so nothing is missed, and a removed user or revoked token is refused at the door.

Tailing a Log

GET /events/log/<run_id>?stream=stdout|stderr&attempt= is the same idea for a log file: it sends the last 200 lines, then every second the new whole lines (at most 64 KiB per tick), and an end event a moment after the attempt has finished. If the file shrinks (it was rotated) it starts again from the top with a reset line. A reload that swaps the users or tokens ends open tails too (without end), and the viewer reconnects and starts the tail afresh.

Try It

import pynet.loop

from pyscheduler.ui.live import LiveFeed

feed = LiveFeed(buffer=3, boot="demo")
for n in range(5):
    feed.publish("run", {"run_id": f"job.2026-10-19.{n}", "state": "succeeded"})
print(feed.last_id)  # demo-5


async def listen(last_heard: str, count: int) -> list[tuple[str | None, str | None]]:
    stream = feed.stream(last_heard)
    heard = []
    for _ in range(count):
        event = await anext(stream.source)
        heard.append((event.id, event.event))
    return heard


# only the last 3 are kept (ids 3, 4, 5), so "heard 3" gets 4 and 5 ...
print(pynet.loop.run(listen("demo-3", 2)))  # [('demo-4', 'run'), ('demo-5', 'run')]
# ... but "heard 1" is too old to fill the gap: one reset
print(pynet.loop.run(listen("demo-1", 1))[0])  # ('demo-5', 'reset')
# ... and so is an id from another boot
print(pynet.loop.run(listen("other-9", 1))[0])  # ('demo-5', 'reset')