The fifth 1.3 release candidate. It ships the 19 task-shaped tutorials in docs/website/guides/ (#3173), so the docs site can render them from djust itself, plus rounds 2 and 3 of the open-issue drain (#3204).
- Explicit exposure: a state save no longer times out under load (#3200), a view whose session vanished mounts fresh (#3201), and the SSE and
enable_state_snapshotfollow-ups (#3211, #3212, #3221). - Presence:
broadcast_to_presence()reaches a templatedpresence_key(#3202), and Redislist()costs two commands (#3203). - Transport and render: the WebSocket honors
FORCE_SCRIPT_NAME(#3186), SSE session-registry fixes (#3164), off-loopset_layout()renders (#3178), and thedj-roottemplate-comment fix (#3187). - Docs: a new Scaling djust guide (#3216) and aligned deployment docs (#3219, #3210).
- Tooling:
djust deploy logs(#3195),djust deploywithout a TTY (#3112), and djust's config followingoverride_settings(#3217, #3218).
Behaviour changes to check when upgrading from rc4:
- New system check
djust.C023warns about achannels_redislayer that redis-py 8 would time out (#3199). - The
djusttheme preset's light-mode link colour is darker, for WCAG AA contrast (#3165). T012acceptsdj-rooton the root element (#3171).- djust's config reloads on
setting_changed; values set withconfig.set()survive the reload (#3217).
Added
djust deploy logs [<slug>] [--deployment <id>] [--follow]prints a deployment's build/deploy log (#3195). Until now the log of a failed deploy was only visible in the djustlive dashboard. The slug defaults to[tool.djust.deploy].projectinpyproject.toml, and without--deploymentthe project's most recent deployment is shown.--followkeeps printing new lines until the deployment finishes and exits 1 if it failed or was cancelled; connection errors, timeouts and 5xx responses are retried up to five times in a row with backoff. Log lines go to stdout and the deployment's status to stderr. Credentials resolve the waydjust deployresolves them: an expired access token is refreshed, each time it expires during a long--follow, and a log query never opens a browser login. These are the platform's build and rollout lines, not the running app's own output.logsjoinslogin,logoutandstatusas a reserved word afterdjust deploy: a project with the sluglogsis deployed withdjust deploy deploy-dir logs. Ctrl-C in anydjust deploycommand now printsAborted.and exits 130 instead of printing an emptyError:and exiting 1. Needs a djustlive server with the build-log API; older servers answer 404. New cases inTestLogsCommand.- System check
djust.C023warns when achannels_rediscore layer would drop idle WebSockets under redis-py 8 (#3199). redis-py 8 lowered its defaultsocket_timeoutfromNoneto 5 s, the same valuechannels_redis.core.RedisChannelLayerpasses toBZPOPMIN, so an idle consumer's read times out and its WebSocket closes every few seconds (django/channels_redis#422). The check fires when aCHANNEL_LAYERSentry uses that backend, redis-py 8 or later is installed, and a host does not setsocket_timeoutabove 5 s (orNone); URL and(host, port)hosts count as the default, a?socket_timeout=in the URL overrides the dict's key (as in redis-py), subclasses of the core layer are checked, and settings shapes channels_redis itself rejects are skipped instead of crashingmanage.py check.djust doctornow probes a dict host at itsaddress(orhost/port) instead of pinging localhost under the configured host's label. The channel-layer snippets in the deployment, server-push and error-codes guides now use the dict host form{"address": REDIS_URL, "socket_timeout": 20}(20 since #3210). New cases intest_c023_redis_socket_timeout_3199.py.
Fixed
djust deployno longer fails with a bareError:when run without a TTY, anddjust deploy statusrefreshes an expired login (#3112). Deploying to a project that did not exist yet asked "Create it now? [Y/n]", which read end-of-file from a script or CI job and aborted with no explanation; it now stops before prompting and says to pass--yes(or--no-create). The slug prompt, reached when no slug is given and none is saved inpyproject.toml, likewise fails with its "pass it as a positional argument" message.djust deploy statussent the saved access token without checking it, so an hour after logging in it failed with a rawAPI error 401; it now resolves credentials the waydjust deploydoes, refreshing an expired access token silently and, when the refresh token is dead too, asking you to rundjust deploy loginrather than opening a browser. New cases inTestNoTtyProjectCreationandTestStatusTokenRefresh.SSE session registry: a reused
session_idno longer replaces another owner's live session, the caps hold under concurrent GETs, and an early disconnect no longer leaks a registration (#3164). A stream GET with a client-chosen id that was already live under a different owner overwrote it, so the victim's POSTs hit the new session (403/404), and the replaced stream'sfinallythen popped the NEW session. That GET is now refused with 409, checked before the mount and again, atomically, at registration. Only a live session blocks: one whose stream has closed or is closing (in its linger window, or shut down) is replaced, and the same owner re-using its id (EventSource auto-reconnect) always replaces its own. A tab whose owner changed while its stream was still live (a login or logout in another tab) gets the 409; since EventSource never retries a non-200, the client now retries once with a fresh session id when a connection, or EventSource's own reconnection after a drop, fails before itssse_connectack. A stream'sfinallyremoves the registry entry only if it is still that stream's session. The per-client (429) and global (503) caps counted, awaited the mount, then registered, so concurrent GETs overshot them, truly in parallel underdjust serve --loops N; a GET now reserves its slot under athreading.Lockbefore the mount, and every exit converts the reservation into the registration or releases it, including a failed, unauthorized or raising mount. A second release of one reservation is logged as an error instead of being clamped, and the 503 log counts in-flight reservations. Separately, thesse_connectack was sent before thetrywhosefinallyunregisters, so a client that read the ack and disconnected left its session registered forever, and a loop of those filled the global cap; the ack is now inside thetry, and a stream whose response Django closes before it starts, or that has not started 30 seconds after registration (the handler was cancelled before it could close the response), unregisters and is shut down too; the start-deadline timer is cancelled as soon as the stream starts or is abandoned, so it never holds a session in memory. The one fresh-id retry is re-armed by a navigation (a new mount orlive_redirect). New tests inpython/djust/tests/test_sse_registry_3164.pyandtests/js/sse_fresh_id_retry_3164.test.js.The
djusttheme preset's light-mode link colour passes WCAG AA (#3165).linkwas the bright brand orange fill (28 80% 53%), 2.66:1 on the page background, and.link/.text-linkrender it as body text. It is now28 80% 35%(5.3:1 on the background, 5.5:1 on cards, 4.7:1 on muted), withlink_hoverat28 80% 29%; the hue is unchanged and dark mode already passed.primarykeeps the bright fill because it carries the dark ink labels from #2996, soprimaryused as text on the background still measures 2.66:1. The link/background pair is not yet inCONTRAST_PAIRS: 46 of 136 preset-modes still fail it (54 forprimary), and that matrix change belongs with the legacy-palette remediation in #2885. New cases intest_djust_preset_link_contrast_3165.py.The Python
Toastclass is fully styled bydjust_components/components.css(#3166). Since #3161 its.dj-toastroot picked up the card styling, butdj-toast-<type>,dj-toast-messageanddj-toast-dismisshad no rules and no stylesheet read the--dj-toast-*properties its docstring lists. Each type now takes its tone on the border (erroruses the theme's--destructive), the message and dismiss button share the{% toast_container %}rules, and--dj-toast-bg/-fg/-border/-radius/-padding/-shadowand the per-type--dj-toast-<type>-bg/-fg/-borderare read first, each falling back to a theme token. The text stays on the card foreground, so no label's contrast depends on a status hue. NewToast-<type>cases intest_every_class_has_a_rule_in_a_linked_stylesheetandtest_the_toast_class_properties_are_read_by_the_stylesheet.djust initsaid the system check found "no issues" when it had printed warnings (#3170).manage.py checkexits 0 for warnings, and init only looked at the exit code. It now reads Django'sSystem check identified N issuessummary, reportscheck done N issues reported (see below), and prints the check output under the table. The exit code is unchanged, since warnings do not fail the check. New cases inpython/djust/tests/test_djust_init.pyrun the realmanage.py checkon astartprojecttree.System check
djust.T012now recognisesdj-rooton the root element (#3171). The warning no longer fires whendj-rootis present withoutdj-view, matching the documented LiveView pattern wheredj-rootis authored anddj-viewis injected at runtime. The warning message and hint now recommend addingdj-root.djust init --dry-runlabelled the settings and ASGI rowsdone(#3172). The table under "Dry run: nothing was written" readdone djust block appendedanddone replaced Django's default. Those rows now readwould changewith what would happen (append djust block,replace Django's default,create). Rows that areunchangedor need attention keep their status, and a real run still reportsdone.A
set_layout()swap renders off the event-loop thread (#3178). Both_flush_pending_layouttwins ranview.get_context_data()on the loop thread (the consumer's also ranrender_to_stringthere), so a layout turn did sync context and template work on the per-process bottleneck, outside the session's worker thread (ORM access inget_context_datasaw a different thread and DB connection, or raisedSynchronousOnlyOperation). Both now call one sharedrender_pending_layout(view, path)in a singlesync_to_asynchop, like the other render paths: on the session's pinned worker withLIVEVIEW_CONFIG["worker_threads"], else on asgiref's thread-sensitive thread. Error handling is unchanged. New tests inpython/djust/tests/test_layout_render_off_loop_3178.py.The version-sync check now covers the shipped SBOM (#3184).
scripts/check-lockfile-versions.py(run bymake release, the CI test job and a pre-commit hook) now also fails when the rootdjustcomponent ofpython/djust/djust.cdx.jsonnames a version other thanpyproject.toml's. The 1.3.0rc4 cut needed a manual commit because the SBOM still said rc3.make versionalready regenerates the SBOM (added with it in #3136); a test now pins that. New cases inTestSbomVersion.The client's WebSocket honors
FORCE_SCRIPT_NAME/SCRIPT_NAME(#3186).connect()always openedws[s]://<host>/ws/live/, so an app mounted under a path prefix connected to whatever served/ws/live/at the host root.{% djust_client_config %}now also emits<meta name="djust-ws-path" content="<script prefix>ws/live/">(both template engines), the client resolves it once at bootstrap aswindow.djust.wsPath(an explicitwindow.djust.wsPathset before the bundle wins), andconnect()builds the socket URL from it withwindow.djust.wsUrl(), falling back to/ws/live/when the tag is absent. Only a root-relative path is honored, so the socket always targets the page's own host. A newDJUST_WS_PATHsetting, when set, is emitted verbatim instead. The server must answer at the prefixed path: the deployment guide's new "Serving the app under a path prefix" section covers a prefix-stripping proxy,uvicorn --root-path(Channels'URLRouterremovesroot_path), or an explicitpath("app/ws/live/", ...)route.wsPathandwsUrl()are declared indjust.d.ts. New tests inpython/djust/tests/test_ws_path_script_name_3186.py(through Django'sWSGIHandler, since the test client never sets the script prefix),tests/js/ws_path_script_name_3186.test.js, and the5ccases inpython/djust/tests/test_client_config_tag.py.Upgrade note. A deployment that already sets
FORCE_SCRIPT_NAME(or passesSCRIPT_NAME/ an ASGIroot_path) and uses{% djust_client_config %}has so far only been able to use the host-root/ws/live/, so its proxy may route only that. After upgrading, the client connects to<prefix>/ws/live/. If that first handshake fails before the socket has ever opened, the client tries/ws/live/once and logs aconsole.warnnaming the URL that failed. The root path is adopted for the rest of the page only if that socket opens; if it fails too, the reconnect backoff goes back to the prefixed path. So the page keeps its WebSocket at the cost of one failed handshake per load. To remove that cost, route<prefix>/ws/live/to djust, or setDJUST_WS_PATH = "/ws/live/"to keep the old path; system checkdjust.C025reports aDJUST_WS_PATHthat is not a path starting with a single/, which the client ignores with aconsole.warn. A correctly routed deployment makes the same one attempt when its first handshake fails for an ordinary reason (a restart, a rollout); on a shared host where another app answers at/ws/live/, that attempt can open there, and the page stays on it. A per-site shim that rewrote the socket URL to add the prefix (djust-docs'ws-prefix.js) is now redundant, and harmless, since it rewrites only an exact/ws/live/.A
<div dj-root>written inside a Django template comment no longer doubles the page (#3187). A LiveView whose template{% extends %}a base and mentions<div dj-root>in a{% comment %}block (or{# ... #}), before the real root, rendered a second full HTML document inside the base's<main>on the initial GET: locating the root in the template source took the commented tag as the root, so the VDOM template became the slice from the comment to the real root's close. docs.djust.org/search/ hit this with a comment explaining why itsdj-viewis written out; it did not depend on the explicitdj-view. The root search in template source (get_template(),_extract_liveview_root_with_wrapper,_extract_liveview_template_content) now masks Django template comments, length-preserving, so a comment neither selects the root nor moves the close-tag balance inside it. The masking tokenizes as Django's lexer does:{% verbatim %}blocks are text, the tag name must be exactlycomment({% comment-box %}is not one),{{ }}is taken before{#, and{# #}does not span lines. Rendered-HTML searches are unchanged. New tests inpython/djust/tests/test_extends_double_document_3187.py.An explicit view's per-event state save no longer times out under load and tells users to reload (#3200).
commit_explicit_turngave the save a 150 msasyncio.wait_forthat also counted two or moresync_to_asynchops queueing behind every other session's work on the shared sync thread, so a busy process answered ordinary events with "State unavailable. Please reload the page." The root and child-tree saves now run as one sync call in one Django-thread hop, and the deadline starts when that call starts running, so queue time is not counted. The deadline is configurable withDJUST_EXPLICIT_STATE_SAVE_TIMEOUT(seconds, greater than 0 and at most 10; default 0.15), and new system checkdjust.C024reports an invalid value. Saves of one runtime are now ordered: a save still running past its deadline delays the next one, so a late write can no longer overwrite a newer, already acknowledged one (it could before, when turns ran on different threads, as SSE requests do). A save that still outruns the deadline keeps ADR-038's guarantee that the browser never shows a state storage does not have: the success frame is withheld and the next update is full HTML. But the error is now markedtransientand says the change is still being saved, and once storage answers ONE catch-up turn re-authorizes, commits the kept values with a 10 s deadline and sends full HTML without waiting for the user. The catch-up never schedules another one. If it cannot deliver (storage still not answering, authorization refused, its own save or render failing), if three deferrals come in a row, or if a save has run for 10 s, the error is the terminal reload error, as before. At mount, where there is no page to catch up, a slow child save is still the reload error. A refused save (the session identity changed) or a storage error is still the terminalstate_error, and a save failure nobody is waiting for any more is now logged. Also fixed (#3097): astart_asyncresult never rendered over SSE, because the background task inherited the event POST's asgiref executors, which die when the POST returns ("CurrentThreadExecutor already quit"); root and child-view background tasks, and the catch-up turn, now run their sync work on the default sync thread, and the ADR-038 E5 browser matrix now requires the SSE result to render. Storage-failure tests now inject through the sync session save. New cases inpython/djust/tests/test_explicit_save_deadline_3200.pyandpython/djust/tests/test_exposure_sse_resilience_3200.py.An explicit view mounts fresh over the WebSocket when its session no longer exists (#3201). After a Redis flush or restart with cache sessions, eviction, expiry or
clearsessions, the browser still presents the old session cookie. The mount's first storage read cleared that key, and binding the view's state to it then failed with "Protected view operation failed" on every reconnect, so the page was stuck until a manual reload. The mount now creates a replacement server session in that case, re-derives the user from it (anonymous, since a lost session cannot vouch for the user the socket connected as) and runsmount()with nothing restored. Only a key that was presented and then found missing is replaced: a socket with no session cookie is refused as before and creates nothing, a session that still exists keeps its identity, and repeated mounts on one socket reuse one replacement. A WebSocket replacement never reaches the browser, so it expires afterDJUST_SERVER_STATE_MAX_AGE; an SSE mount runs on the real stream request, whose response issues the replacement as a cookie with Django's normal lifetime, so a later login does not inherit a short one. Withcachesessions Django treats a failed cache read as a missing session, so a transient read error also mounts anonymous (the real session survives). A live socket whose session disappears is still refused with 4403, because that is how a logout revokes it; the reconnect that follows now mounts fresh. New cases inpython/djust/tests/test_exposure_stale_session_mount_3201.py.broadcast_to_presence()never arrived for a templatedpresence_key(#3202). The WebSocket transport joined the presence group beforemount(), sopresence_key = "chat:{room}"could not be formatted yet: every session joined the unformatted groupdjust_presence_chat:{room}(shared by every room), each mount loggedPresence key format error, andbroadcast_to_presence()sent to the formatted group, which nobody had joined. The presence group is now joined aftermount()or a session restore, fromon_mount_render_ready, with the key read on the session's thread. The consumer records every presence group it joined, so amount_batchwith several presence views leaves all of them on disconnect, and alive_redirectnow leaves the old view's presence group (before, a session that navigated from one room to another kept receiving the old room's broadcasts and stayed in its group after disconnect). OnlyPresenceMixinviews join a presence group; aTenantMixinview without presence no longer joins one.LiveCursorMixincursor broadcasts go throughbroadcast_to_presence()and are fixed with it. Behaviour change: because the join now followsmount()and the first render, a presence broadcast a peer sends while a session is mounting is not delivered to that session; the online count is unaffected, since it refreshes through server push. A session whose presence key changes after mount (for example by a handler switching rooms) still stays in the group it joined at mount. 5 regression tests inpython/djust/tests/test_presence_templated_key_3202.py.Redis presence
list()cost 2 + N Redis commands on every render (#3203). Each call rancleanup_stale(aZRANGEBYSCORE, plus a MULTI/EXEC write pipeline when anything was stale), a secondZRANGEBYSCORE, and oneHGETper member in a MULTI/EXEC pipeline;list_presences()inget_context_dataandonline_countcall it on every render of every session, which made presence the largest Redis load in a multi-pod chat. In bothRedisPresenceBackendanddjust.tenants'TenantAwareRedisBackend, a read is now oneZRANGEBYSCOREand oneHGETALL, pipelined without a transaction, whatever the member count.list()runscleanup_staleat most once per cleanup interval per presence key per process, on the monotonic clock; the interval defaults to 30 s and is set withDJUST_CONFIG['PRESENCE_CLEANUP_INTERVAL'](or the backends' newcleanup_intervalargument). A stale member is still left out of the list, because the read filters on the heartbeat score. Callingcleanup_stale()directly is unchanged. 12 regression tests (test functions; most are parametrized over both backends) inpython/djust/tests/test_redis_presence_list_cost_3203.py, which count the commands fakeredis receives.Custom and djust filters could disappear from root LiveView renders for the rest of the process (#3208). The one-time bridge that sends every Django library filter to the Rust engine runs on the first render. When that render went through a separate
DjustTemplateBackend(a second template backend, or afrom_stringon one), the filters were registered in that backend's registry namespace only. After that,{{ view|field_value:"name" }}in a root LiveView failed withInvalid filter: 'field_value'.- The bridge now always writes the global namespace. That is where
DjustConfig.ready()'s startup warm-up already put the filters, so this changes behaviour only for projects withLIVEVIEW_CONFIG['filter_bridge_warm'] = False. For those projects, app template libraries' filters now reach every backend, not only the backend whose render ran first. - If a bridged filter later goes missing from the global registry (for example after
clear_custom_filters()), the bridge re-registers only the missing names, each with the callable it originally registered. It never touches a name that is still present, so a same-named filter that a{% load %}registered is not swapped out. - New cases in
python/djust/tests/test_filter_bridge_global_registry_3208.py.
- The bridge now always writes the global namespace. That is where
Theme colours that browsers dropped or misread because a bare HSL token was used as a colour (#3209). Theme tokens are bare HSL triplets (
--link: 28 80% 35%), socolor: var(--link, var(--primary))computes to a string that is not a colour and the declaration is discarded.- Every such use now goes through
hsl():.page-linkand the auth-page footer links (the citedpages.css:70and:87), nine more rules inpages.css(page title, descriptions, back link, error code, ETA, social divider) and the catalogue's.dc-version. .hover-glow'srgba(var(--primary) / 0.3)indesign_system_css.pywas not dropped but computed to a wrong, yellowish colour; it is nowhsl(var(--primary) / 0.3).- The outlined button (the
neo_brutalistandretrodesign systems) is readable on hover. Its hover rule setbackground: currentColorwith acolorthat was dropped, so the hovered button was one solid block of the inherited text colour. It now sets the inverse explicitly:backgroundandborder-colorhsl(var(--foreground)), texthsl(var(--background)). - The
Meterdocstring example passeshsl(var(--primary)), sinceMeterwrites the string straight into astyleattribute. python/djust/tests/test_theming_hsl_token_usage_3209.pyscans every stylesheet, template, JS file and CSS-emitting Python file in the package (inline<style>blocks and quoted"color": "…"mappings included) and fails on a barevar(--<token>)outsidehsl()/hsla(), with the token list read from the theme generator's own output.python/djust/tests/test_theme_button_hover_contrast_3209.pyresolves each design system's hovered.btnagainst every preset, light and dark, and fails if its text colour equals its background.
- Every such use now goes through
An explicit view that also sets
enable_state_snapshot = Trueno longer acknowledges a failed state save (#3211). The flag is the legacy snapshot opt-in, but on the event path it sent an explicit view to the legacy best-effort save, which logs a failed or slow write and answers the event with apatchanyway. That bypassed ADR-038 E3 (the browser was shown a state storage did not have) and the #3200 deadline fix. Explicit views now always commit throughcommit_explicit_turn, whatever the flag says, as mount already did. The same applies to the parallel paths:- Component events on explicit views. Every event routed to a component of an explicit view (by
component_id) now commits the view's declared state before its frame, with or without the flag. That covers a component declaration's output handler (for example an ADR-034DropdownMenu) and a component whose handler writes the view's fields throughsend_parent. Before, such a write was saved best-effort only with the flag and not at all without it. Visible change: every component event on an explicit view, including a click that changes nothing, now writes storage before answering, and a failed or slow write now answersstate_error(or the transient "still being saved" error) instead of anhtml_update. Waiters see the event even when its save fails, as on the view-event route. - Sticky children. The sticky-child save predicate now also requires both the child and the parent to use the legacy policy, since a legacy save under an explicit parent can never be restored.
Legacy views are unchanged. New cases in
python/djust/tests/test_exposure_snapshot_opt_in_3211.py.- Component events on explicit views. Every event routed to a component of an explicit view (by
Three follow-ups to the explicit-exposure save work (#3212).
- An SSE event whose save outran its deadline no longer blocks the event loop. Django serves each request inside a per-request
ThreadSensitiveContext, and when the request ends that context shuts its single-thread executor down and waits for it. An explicit save that missed its deadline (#3200) kept running on that executor after the event POST answered, so the whole event loop stopped until storage answered; under sync middleware the request's bridge thread was held instead. A save made inside a request's executors now runs on a small pool of long-lived threads (djust-state-save,min(32, 4 × CPUs)), so the request ends when its turn does. Each save job closes stale database connections before and after it runs, as a request does, soCONN_MAX_AGEand connection health checks apply and a connection broken under the thread (a database failover) is replaced. One hung store write holds one thread, not every other session's saves, and a request-scoped save that cannot get a pool thread within 5 s is deferred with the transient "still being saved" error, like a slow one, rather than waiting indefinitely. The escalation to the reload error counts only the time a save has actually been running, not time spent queued. WebSocket saves are unchanged: they still wait for their session's thread without a bound (#3206). A navigation or reconnect over SSE builds a new runtime; its first save now waits for the old page's still-running save, so that one can never land over the new page's writes. Astart_asynccallback spawned by the POST had the same problem and is now detached from the request's context too, not only from a sync bridge. WebSocket sessions (including worker-pool slots) andPooledHTTPrequests are unchanged. live_redirectre-checks sticky children as the user the new mount runs as. The re-check used the socket's connect-time user. Since #3201 an explicit mount whose session has vanished runs as anonymous, so alogin_requiredsticky child survived into a page that mounted anonymous. The re-check now derives the user the same way the mount does, from the policy of the view that will actually be mounted (chosen and resolved as the mount does it: the URL's view, or the client's view for a back-navigation carrying a state snapshot; a legacy page redirecting to an explicit one included), and shares the mount's replacement session. A redirect to a legacy page keeps the connect-time user and creates no session.- A legacy view's best-effort state save no longer counts thread queueing against its 150 ms.
_persist_state_after_eventand_persist_sticky_child_after_event(views withenable_state_snapshot = True) wrapped severalsync_to_asynchops in one 150 ms timeout, so a busy process dropped saves that storage would have finished in milliseconds. Each is now one sync call in one Django-thread hop, with the deadline starting when it starts running, as #3200 did for explicit saves. A timeout or failure is still only logged. A save that times out is no longer cut short and may land late, so the saves of one view are now ordered: a late save can never overwrite a newer one.
New cases in
python/djust/tests/test_sse_save_teardown_3212.py,python/djust/tests/test_state_save_pool_3212.py,python/djust/tests/test_exposure_live_redirect_user_3212.pyandpython/djust/tests/test_legacy_save_deadline_3212.py.- An SSE event whose save outran its deadline no longer blocks the event loop. Django serves each request inside a per-request
djust's config now follows
override_settingsfor every setting it reads (#3217). Those settings areLIVEVIEW_CONFIG,DJUST_CONFIG,DJUST_WS_COMPRESSION,DJUST_VDOM_CACHE_*andDJUST_STATE_SNAPSHOT_ENABLED. The config singleton read them once and cached them, so an override did not take effect until something calledconfig.reset(). A reset done inside an override then outlived it: in djust's own suite,reauth_on_event=Trueleaked into later tests on the same worker and closed their sockets. The config now reloads on Django'ssetting_changedsignal, on entry and on exit.- Values set in code with
config.set()/config.update()(for example in a project'sAppConfig.ready()) survive the reload. An override that sets the same key wins while it is active, and the code-set value returns when it exits.config.reset()still discards them. - The reload builds the new config and swaps it in with one assignment, so readers on other threads never see defaults partway through.
DJUST_VDOM_TRACEis unset again when adebug_vdomoverride exits, unless the environment set it.- Tests in projects that override
LIVEVIEW_CONFIGno longer need a manualconfig.reset(). - New cases in
python/djust/tests/test_config_follows_override_settings_3217.py.
- Values set in code with
Follow-ups from #3213: filter restore and config edges (#3218).
- After
clear_custom_filters(), a bridged filter that a{% load %}had taken over is now restored from the library that owns it. Before, the restore skipped it, so a template using that filter without{% load %}failed withInvalid filteruntil the next{% load %}. config.get()andconfig.as_dict()return copies of dict, list and set values, andset()/update()store copies. An in-place edit of a returned dict used to change the live config without being recorded, so the next reload (anyoverride_settingsof a config setting) silently undid it. Nothing in djust edited returned values in place; useconfig.set().set()/update()write and record under one lock shared with the reload, so a reload on another thread can no longer land between the two and drop the value. Every writer is copy-on-write (builds a new dict and swaps it in), so the lock-freeget()/as_dict()copy never races an in-place change (dictionary changed size during iteration).- The test tying
_CONFIG_SETTINGSto the settings the config reads now scans the whole ofpython/djust/config.py(helpers, aliased imports, and the literal alias table) instead of one method, and fails on a read it cannot resolve. Covered by new cases inpython/djust/tests/test_config_follows_override_settings_3217.pyandpython/djust/tests/test_filter_bridge_global_registry_3208.py.
- After
A normal SSE stream close now disposes an explicit view (#3221). When the client went away, the stream lingered and removed the session from the registry but never called
session.shutdown(), so an explicit view and its children were never disposed:start_asyncwork kept running and rendering into a dead queue, waiters stayed pending, upload temp files stayed behind and unregister hooks never ran. The WebSocket path tears all of this down on disconnect. The stream now shuts its own session down after the linger, once any event POST still being dispatched on it has finished (waited for up to 10 s, then disposed anyway). When an EventSource reconnect has already replaced the session under the same id, the old session is still disposed, since nothing routes to it any more, and the reconnect's session and view are untouched. Two things to know:SSESession.shutdown()disposes only explicit views, so a legacy view's background work is still not cancelled on close (tracked separately); and an explicit view's app hooks (_cleanup_on_unregister,_on_sticky_unmount) now run after the linger, about 5 s after a reconnect's mount, the reverse of the WebSocket order, so a hook keyed by user or session identity (an "editing" lock, app-level presence) can undo what the new mount just set up. New cases inpython/djust/tests/test_exposure_sse_close_3221.py.
Documentation
- The 19 task-shaped tutorials now ship with djust (#3173). They lived only in the docs site's second content root, so the tutorial links in
docs/website/getting-started/first-liveview.mdanddocs/website/getting-started/core-concepts.mdwere dead on GitHub. They are now indocs/website/guides/(tutorial-*.md), listed in the Guides navigation, and checked against the current API: code that had drifted since 1.2 is corrected. - New guide: Scaling djust (#3216).
docs/website/guides/scaling.mdis the entry point for scaling decisions: cost per frame, one process across cores (linking into the across-cores reference), memory settings, several processes or pods with Redis, failover and rolling deploys, a capacity table and a troubleshooting checklist. For multi-pod deployments it states what a measured run on CPython 3.12 needed: shared, durable sessions;enable_state_snapshotorstate(..., persist="server")for state that must survive a reconnect, becauseSTATE_BACKEND = "redis"alone restores nothing;socket_timeoutabove 5 s on the channel layer with redis-py 8 (#3199); one pod of headroom, because connections don't rebalance after a failover; a free pod slot ormaxUnavailable: 1for rolling updates; and readiness probes that don't check Redis.deployment.mdandscaling-across-cores.mdlink to it. - Deployment and scaling docs no longer contradict the Scaling djust guide (#3219, #3210, #3171).
deployment.mdnow says the Redis state backend caches the compiled view as a diff baseline and does not restore a view's state on another process; that the Redis channel layer is required when more than one process serves WebSockets or anything pushes from another process (Celery, management commands, cron), withdjust.layers.InMemoryChannelLayerright for one process with no out-of-process pushes; thatdjust serve --loopsN > 1 needsMultiLoopInMemoryChannelLayeror channels_redis' pub/sub layer; that each--workers/-wworker is a separate process with everything Option B needs; and that uvicorn, gunicorn'sUvicornWorkeranddjust serveuse uvloop by default when it is installed, with--loop asyncio(or aUvicornWorkersubclass) to opt out and #3095's trade-off (13% less loop CPU, 201 of 384 connections lost in one macOS ramp). Redis is advisednoevictionrather thanallkeys-lruwhen it also carries the channel layer or presence, ALB stickiness is marked optional, and the example TTLs are 120 s. Its session section gives theSESSION_TTLsizing rule (entries per session and LiveView page) instead of a per-use-case table and drops the hourlycleanup_expired_sessionscron (unneeded since #3080), pointingdb/cached_dbusers at Django'sclearsessionswith a Celery task body; theworker_threadsand--loopssections are summaries that link to the across-cores guide.scaling-across-cores.mdreplaces its Redis section with a link to Scaling djust, corrects its per-client memory arithmetic (about 2.7 MB each against the #3114 runs' 81 MB idle start, not 2.1 MB against a different setup's 116 MB), and mentions idle-timegc.collect()/malloc_trim(0). The channel layer's recommendedsocket_timeoutis now 20 in every snippet, in thedjust.C023hint and in the docs, with #3210's explanation: it must exceed channels_redis' 5 s blocking read, andNoneis not recommended because a half-open connection then hangs until TCP keepalive notices.scaling.mdnow says #3200 makes the explicit-exposure reload error much less likely under load rather than fixed, anddocs/system-checks.mddescribes T012 as "nodj-rootordj-view" (#3171). New cases intest_c023_redis_socket_timeout_3199.pypin the hint's value to the guides' and the demo project's snippets; the demo docs templates now setsocket_timeout20, and the T015 docstring quotes T012's current wording.