--- title: "1.32.0 — Realtime keepalive and fewer per-request database writes" description: "Realtime sockets now get a server keepalive and a 90-second idle window. Unset settings, chat delivery, API-key stamps, failed GeoIP lookups and routine 4xx incident events no longer cost SQL on every request or frame. This is a minor release because the account User no longer writes its realtime co…" date: "2026-10-02" tags: ["release"] canonical: "https://django-mojo.sitesmojo.com/changelog/1-32-0/" --- Realtime sockets now get a server keepalive and a 90-second idle window. Unset settings, chat delivery, API-key stamps, failed GeoIP lookups and routine 4xx incident events no longer cost SQL on every request or frame. This is a minor release because the account User no longer writes its realtime connection flag and routine 4xx incident events are now written by a job, which needs the jobs engine running. ### Breaking - A realtime connect or disconnect no longer writes the account `User` row. `User.metadata.realtime_connected`, `realtime_connected_at` and `realtime_disconnected_at` are no longer updated; existing values stay frozen at whatever was last written. - The account `User` no longer defines `on_realtime_connected` / `on_realtime_disconnected`. A subclass that calls `super()` on either now raises `AttributeError`. Custom identity models keep their hooks. - Incident events for routine 4xx responses from the REST dispatcher (permission denials, `mojo_rest_error` including 404s, `api_denied`, `rest_value_error`) are written by a job on the `incident_handlers` channel a moment after the response, not before it. With no runner consuming that channel they wait in Redis and are dropped after one day. - `WS_UNAUTH_TIMEOUT`, `WS_CONNECT_RATE_LIMIT` and `WS_MAX_CONNECTIONS` are read once from Django settings when the realtime handler loads. A database `Setting` row for any of them is now ignored, and changing one needs an ASGI restart. ### Added - Server keepalive: authenticated sockets receive `{"type": "ping", "ts": }` every `WS_SERVER_PING_SECONDS` (default 20, `0` disables). A client `{"type": "pong"}` resets the idle timer and gets no reply. - `mojo.apps.realtime.signals.realtime_connection_changed(sender, user, connected, connection_id)`, sent once per authenticated socket on connect and on disconnect, after Redis presence has changed. A receiver that raises is logged and never reaches the socket. - Chat publishes `chat_member_removed`, `chat_member_banned` and `chat_room_deleted` on the room topic after the write commits, alongside the existing `chat_member_left`. - `WS_SUBSCRIPTION_RECHECK_SECONDS` (default 300, `<= 0` re-checks every frame): how long a socket trusts a chat access decision. - `GEOIP_FAILURE_TTL` (default 3600 s): how long a failed GeoIP lookup is cached before it is retried. - `report_event(..., defer=True)` queues an incident event instead of writing it inline. Direct `report_event` callers are unchanged unless they opt in. - `INCIDENT_SYNC_CATEGORIES` (settings file only) adds categories that always write inline, on top of the built-in security list. ### Changed - The authenticated idle cull is `WS_IDLE_TIMEOUT`, default 90 s (was a hard-coded 30 s). Only frames from the client count as activity; server pings do not. - `pong` is now a reserved client message type. It no longer reaches `on_realtime_message` or `REALTIME_MESSAGE_HANDLERS`. - `settings.get` caches misses per scope: after the first lookup, an unset key costs one Redis read per scope and no SQL. A `Setting` save invalidates at once; each cache hash expires after one hour as a backstop. - With Redis down, `settings.get` reads the database for every scope instead of falling through to the settings file. - Chat delivery checks room access once per subscription instead of on every frame. Leave, remove, ban and room delete still cut delivery on the next frame; any other loss of access (a revoked `chat` / `manage_chat` permission, a deactivation outside the disable service) takes effect within `WS_SUBSCRIPTION_RECHECK_SECONDS`. - A failed GeoIP lookup is stored with `provider: "failed"` and served from cache until `GEOIP_FAILURE_TTL` passes, instead of re-running the provider chain on every event. The `refresh` action retries it immediately; a record that resolved before keeps its data on a failed refresh. - API-key `last_used` (both `ApiKey` and user API keys) is rewritten at most once every `API_KEY_TOUCH_SECONDS` (default 300, settings file only) instead of on every request. - A deferred 4xx event's `created` is when the job wrote it. 5xx errors and security categories (auth failures, invalid or expired tokens, threat-intel hits) still write before the response. ### Upgrade notes - Clients that cannot tolerate an unknown `{"type": "ping"}` frame: set `WS_SERVER_PING_SECONDS = 0` until they answer it with `{"type": "pong"}`. - Anything reading `User.metadata.realtime_connected` must move to the `realtime_connection_changed` signal or `realtime.is_online`. The old key does not error; it just goes stale. - Deferred 4xx incident events need the jobs engine consuming `incident_handlers`, which is a default channel. A deployment that narrowed `JOBS_CHANNELS` must add it back, or those events are silently lost after a day. - Tests that count 4xx incident events right after a response must run the queued job first (`th.run_pending_jobs(channel="incident_handlers", ...)`), including zero-count checks, which otherwise pass vacuously. - `GEOIP_FAILURE_TTL` may be lowered where geo signals feed risk scoring, so an address that failed once is retried sooner. - A setting created outside `Setting.save()` (queryset `update`, `bulk_create`, raw SQL, a fixture load) can read as unset for up to an hour on a host that already cached the miss. Call `push_to_cache()` on the row after such a write. - Move any database `Setting` rows for `WS_UNAUTH_TIMEOUT`, `WS_CONNECT_RATE_LIMIT` or `WS_MAX_CONNECTIONS` into the settings file. Every `WS_*` value needs an ASGI restart to change.