Shared Room Clocks
Run one process-local step or poll per tenant and room, with presence-driven lifecycle.
On this page
djust.clocks is experimental, opt-in in 1.3.x, for at least one minor
release. A RoomClock runs a function once per interval for a room, regardless
of how many tabs watch it. tick_interval and handle_tick still run per
session; existing views create no clock tasks, pool or membership polls.
These clocks are process-local. Route a room and its in-memory state to
one process. Multiple event loops in that process are supported with
MultiLoopInMemoryChannelLayer. Redis transport does not elect an owner:
sessions spanning processes or uvicorn --workers N can double-step a room.
Distributed ownership, fencing and Redis leases are deferred (ADR-042 Phase 4).
Quick start: presence starts and stops the clock
import threading
from djust import LiveView, PresenceMixin
from djust.clocks import ClockTick, RoomClock, Publish
_snapshots = {}
_snapshot_lock = threading.Lock()
def beat(tick: ClockTick):
with _snapshot_lock:
_snapshots[tick.scope] = _snapshots.get(tick.scope, 0) + 1
return True
room_clock = RoomClock(
name="lobby",
interval=1.0,
step=beat,
publish=Publish("myapp.views.LobbyView", handler="handle_refresh"),
idle_stop=5.0,
)
class LobbyView(PresenceMixin, LiveView):
template = "<div dj-root>{{ version }}</div>"
presence_key = "lobby"
room_clock = room_clock
def mount(self, request, **kwargs):
self.version = 0
self.track_presence({})
self.handle_refresh()
def handle_refresh(self, key="", **kwargs):
scope = room_clock.scope(self, "lobby")
with _snapshot_lock:
self.version = _snapshots.get(scope, 0)
Put this example in myapp.views, matching Publish.view_path. A real app
must authorize the room before track_presence, and recheck current access
when reading its snapshot. For tenant apps add TenantMixin before the other
mixins. The clock uses the same tenant:<id>: prefix as presence, and binds
that resolved tenant inside both sync and async callbacks. Never bypass
clock.scope(view, key) for a clock's publish subscription or state lookup.
Every connection's track and restore ensures the clock, including a second tab
of an already-present user. HTTP presence tracking is already a no-op and starts
no clock. The binding adds its scope to any existing push_scope subscriptions
and removes its old scope when tracked in another room; it needs one free push
scope slot. In the example its scope is lobby:lobby, or
tenant:<id>:lobby:lobby in a tenant view. Raw key punctuation is percent-encoded
in the scope (e.g. room:same becomes room%3Asame) to keep tenant IDs and
room keys from making ambiguous delimiters. The raw key is the formatted presence
key with its own tenant prefix removed. Membership uses the recorded presence
key, rather than the clock's namespaced scope.
Liveness is read at most once a second. A graceful last leave starts the idle
period using presence's existing count read. A crashed connection disappears
after the presence backend's timeout (normally 60 seconds), then a coarse poll
and idle_stop. A stale recorded membership can keep a clock running until that
expiry. Reconnect reads the current snapshot rather than replaying missed beats.
Explicit lifecycle
Without the optional room_clock attribute, call clock.ensure(self, key) from
authorized WebSocket mount/event/tick code. Assign self.push_scope to
clock.scope(self, key) first. Async code calls await clock.aensure(self, key).
Presence tracking also works in async handlers; its synchronous binding schedules
aensure on the serving loop and observes failures. The first joiner's
handle_presence_join runs after that ensure completes, without blocking the
loop; it may run after the async event handler returns. As with capacity refusal,
a failed ensure cannot guarantee a running clock to the callback. Successful
presence tracking still delivers the join callback if ensure raises (and logs
the failure). If the connection untracks while ensure is pending, the pending
join callback runs before its leave callback; completion cannot deliver it again.
Explicit stop-then-ensure waits at most one second (real time) for the old run to
retire before starting a new run. If it has not retired, ensure returns False;
retry later. The stopping run retains ownership, so its unfinished sync step
cannot overlap a replacement. Presence-bound ensures automatically register one
retry per retiring run and presence room, logged once at debug. As soon as the
old run retires, the retry re-ensures if any participants remain, even if the
joining connection has since left. No further join is required. An empty room
is not restarted. Capacity refusals and factory errors do not schedule this
retirement retry. Payload initialization does not consume the stopping-run wait
budget.
ensure refuses plain management-command/Celery threads and unregistered loops;
async_to_sync there would create a temporary loop that immediately dies.
Supply alive(scope) for membership, or heartbeat ensure more frequently
than idle_stop, with margin (a five-second heartbeat needs around twelve
seconds, rather than the default five). A false, None, or failing alive
starts the idle timer; a new ensure protects the run from a racing idle check.
A pending presence retry temporarily retains its authorized view binding.
Running steps retain no view or request user; they look state up by tick.scope.
Return a truthy value to publish, a falsy value to remain quiet, or
Stop("room reaped") to end a run. Sync steps and liveness callbacks use a
separate shared worker pool with database connection cleanup; async callbacks
run on the owner loop. One key never overlaps itself. A sync step_timeout
marks the run stuck and keeps waiting, because a thread cannot be cancelled.
An async timeout cancels the step and continues with the error policy.
Timing, delivery and diagnostics
The fixed-rate schedule uses monotonic time. on_overrun="skip" drops missed
slots, supplies their count in tick.skipped, and uses
dt = interval * (skipped + 1). "catch_up" runs at most max_catch_up steps
(default three), each with dt = interval, dropping the rest. seq counts
attempted steps within run_id; restart creates a new run ID. fence is always
None. Seeds and authoritative room state belong to the application.
The doorbell uses a payload built once from the raw key (default {"key": key}).
A custom Publish.payload factory runs in the shared clock worker pool, once
per run and outside the registry lock. First-join ensure waits for its result,
including any queue delay from busy clock workers. Keep factories short; this
initialization wait is separate from the one-second stopping-run wait.
It carries no sequence or run ID. Existing push delivery is best effort,
ordered, bounded, and coalesces identical queued events. A busy session gets
one queued doorbell for that key, then reads the latest committed snapshot.
Publish follows the step's completed writes; commit database work before
returning. trailing=1.0 resends once after the last publishing beat. Stop
and idle/graceful stop flush a pending resend immediately; cancellation does not.
Step errors are value-free in logs by default, including in Django DEBUG.
log_details=True opts into exception text and traceback. After ten consecutive
errors, retries back off exponentially up to thirty seconds; a successful step
resets the breaker. Breaker-suppressed slots are intentional pauses: they do not
count as missed beats or contribute to the next step's dt. A failing clock
does not terminate automatically.
clock.stats() reports steps, skipped beats, overruns, last/max duration, run
age, sequence, run ID, stuck status, consecutive errors and stop reason. Recent
stopped records are bounded by max_clocks; expose these only to authorized
operators, because scope keys and application stop reasons can be sensitive.
Defaults cap each clock definition at 256 live keys and 64 per resolved tenant.
Untenanted rooms use the global 256-key limit. Clocks refuse
rather than queue excess keys, and reject intervals below 0.02 seconds. A lazy
process-wide ten-thread pool is separate from session worker_threads.
LIVEVIEW_CONFIG["clock_workers"] changes its size (integer at least two,
chosen before its first use). Each clock definition can occupy at most three
quarters of the pool, with due-time ordering among eligible pending calls.
A hung sync step retains its thread and scope until it returns. Allow a graceful
shutdown grace period longer than your sync callbacks: loop shutdown waits for
in-flight sync work, and a callback that never returns can block interpreter exit.
Variable rate, pause and shared polling
Pass the scoped key to set_interval(scope, seconds), pause(scope),
resume(scope), stop(scope), and running(scope). Rate changes affect the
next slot without restarting. Pause suppresses steps while membership and idle
checks continue; resume does not replay paused time.
SharedPoll takes fetch instead of step, stores the latest (version, result) under a lock, and publishes a doorbell after each successful fetch.
View handlers call poll.latest(poll.scope(self, key)). Late joiners also read
that result during mount. Result retention is bounded by max_clocks; callers
handle None before the first successful fetch or after eviction of an idle key.
Include roles or object permissions in the raw key whenever they affect
visibility. The framework adds the tenant; it does not authorize a caller's
lookup. Per-user results are unsuitable for a shared poll. Prefer db_notify
when a table change can supply the doorbell directly.
Deterministic tests
djust.testing.ManualClock supplies now, sleep, advance(seconds) and
settle(). Pass it as time_source, with DeterministicClockExecutor as
executor, to run due beats without wall-clock waits. That executor runs sync
callbacks inline only in tests. advance jumps time, so use interval-sized
advances for normal beats and a large jump to exercise late-beat handling.
sleep(seconds) measures from the moment it is called, not from when the
returned coroutine first runs, so an advance that lands between the two still
wakes it; a custom time_source must do the same.
The helpers require a serving-loop registration supplied by a real WebSocket
consumer; engine-only tests can use the internal loop registry explicitly.
Wrap synchronous LiveViewTestClient mounts in clocks_disabled() to make
ensure a no-op without needing a serving loop. This switch is context-local.
See the clock API reference, server push, and scaling across cores.