host: durable lazy sessions — logins survive a restart

Sessions were in-memory, so a restart logged everyone out (same class as the
relation wipe). Move them to the durable store, but LAZILY so anonymous/crawler
traffic doesn't spam it: session/create mints a sid with no row; the row appears
on the first session/set (a login). A per-boot epoch (one durable write at
startup, host/session-init!) keeps sids unique across restarts without a write
per request.

- lib/host/session.sx: lazy backend (create = no row, set = create row,
  exists = row written) + epoch/in-memory-counter sid generation.
- serve.sh: point the session store at the durable backend + host/session-init!.
- blog.sx: host/current-principal is now a durable read, so host/auth-footer
  (home + post footers) had to move OUT of the quasiquote into let bindings —
  a perform during page-tree build raises VmSuspended (the whole site 500'd for
  a beat). Principal computed once per page.
- 2 session tests: create writes no row, set creates the row.

249/249. Verified live: site renders (anon + authed), login + footer survive a
container force-recreate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 16:37:26 +00:00
parent 71dd040d80
commit b0b0a0592b
4 changed files with 73 additions and 38 deletions

View File

@@ -326,12 +326,14 @@
(let ((slug (dream-param req "slug"))) (let ((slug (dream-param req "slug")))
(let ((r (host/blog-get slug))) (let ((r (host/blog-get slug)))
(if r (if r
;; Compute the rendered body + related block in let bindings BEFORE the ;; Compute everything that does durable reads — body, related block, AND
;; quasiquote — host/blog--related-block does durable reads, and IO must ;; the auth footer (a durable session read now) — in let bindings BEFORE
;; happen in the handler body, not while the page tree is being built. ;; the quasiquote. IO must run in the handler body, never while the page
;; tree is built (a perform there raises VmSuspended under http-listen).
(let ((principal (host/current-principal req)))
(let ((body-html (host/blog-render r)) (let ((body-html (host/blog-render r))
(related-block (host/blog--related-block slug (related-block (host/blog--related-block slug (not (nil? principal))))
(not (nil? (host/current-principal req)))))) (auth-foot (host/auth-footer req)))
(dream-html (dream-html
(host/blog--page (get r :title) (host/blog--page (get r :title)
(quasiquote (quasiquote
@@ -345,7 +347,7 @@
" · " " · "
(a :href "/" "all posts") (a :href "/" "all posts")
" · " " · "
(unquote (host/auth-footer req)))))))) (unquote auth-foot))))))))
(dream-html-status 404 (dream-html-status 404
(host/blog--page "Not found" (host/blog--page "Not found"
(quasiquote (quasiquote
@@ -364,7 +366,10 @@
posts))) posts)))
(let ((listing (if (> (len posts) 0) (let ((listing (if (> (len posts) 0)
(list (quote ul) items) (list (quote ul) items)
(quote (p "No posts yet."))))) (quote (p "No posts yet."))))
;; auth-footer does a durable session read — bind it BEFORE the
;; quasiquote (a perform during tree-build raises VmSuspended).
(auth-foot (host/auth-footer req)))
(dream-html (dream-html
(host/blog--page "Blog" (host/blog--page "Blog"
(quasiquote (quasiquote
@@ -372,7 +377,7 @@
(unquote listing) (unquote listing)
(p (a :href "/new" "+ New post")) (p (a :href "/new" "+ New post"))
(p :style "margin-top:2em;font-size:0.9em;opacity:0.8" (p :style "margin-top:2em;font-size:0.9em;opacity:0.8"
(unquote (host/auth-footer req)))))))))))) (unquote auth-foot)))))))))))
(define host/blog-index (fn (req) (host/ok (host/blog-list)))) (define host/blog-index (fn (req) (host/ok (host/blog-list))))

View File

@@ -111,11 +111,18 @@ EPOCH=1
echo "(epoch $EPOCH)" echo "(epoch $EPOCH)"
echo "(eval \"(host/blog-load-edges!)\")" echo "(eval \"(host/blog-load-edges!)\")"
EPOCH=$((EPOCH+1)) EPOCH=$((EPOCH+1))
# Session signing secret + admin login credentials, then grant the admin # Sessions on the DURABLE store, LAZILY: only a logged-in session (one that
# principal "edit" on "blog" so a logged-in session passes the ACL gate on the # writes a field) persists, so a login survives a restart while anonymous /
# write routes. Sessions stay IN-MEMORY (default store) — logins reset on # crawler traffic leaves no rows. host/session-init! bumps the per-boot epoch
# restart but the durable KV isn't spammed by anonymous/ crawler sessions # that keeps sids unique across restarts. Then the signing secret + admin
# (lazy session creation is a future lib/dream/session.sx improvement). # credentials, and grant admin "edit" on "blog" so a logged-in session passes
# the ACL gate on the write routes.
echo "(epoch $EPOCH)"
echo "(eval \"(host/session-use-store! (persist/durable-backend))\")"
EPOCH=$((EPOCH+1))
echo "(epoch $EPOCH)"
echo "(eval \"(host/session-init!)\")"
EPOCH=$((EPOCH+1))
echo "(epoch $EPOCH)" echo "(epoch $EPOCH)"
echo "(eval \"(host/session-set-secret! \\\"$SESSION_SECRET\\\")\")" echo "(eval \"(host/session-set-secret! \\\"$SESSION_SECRET\\\")\")"
EPOCH=$((EPOCH+1)) EPOCH=$((EPOCH+1))

View File

@@ -20,26 +20,37 @@
;; ── keys ──────────────────────────────────────────────────────────── ;; ── keys ────────────────────────────────────────────────────────────
(define host/-sess-key (fn (sid) (str "session:" sid))) (define host/-sess-key (fn (sid) (str "session:" sid)))
(define host/-sess-counter-key "session:-counter") (define host/-sess-epoch-key "session:-epoch")
;; mint the next sid from a persisted counter (signature guards guessability) ;; sid generation: a per-BOOT epoch (one durable write at startup) + an in-memory
;; counter. The epoch keeps sids unique across restarts WITHOUT a write per
;; request, so anonymous traffic costs no disk. host/session-init! bumps the epoch
;; on boot (serve.sh); without it (e.g. tests) epoch 0 is fine within one process.
(define host/session-epoch 0)
(define host/session-ctr 0)
(define host/session-init!
(fn ()
(let ((e (+ 1 (or (persist/backend-kv-get host/session-store host/-sess-epoch-key) 0))))
(begin
(persist/backend-kv-put host/session-store host/-sess-epoch-key e)
(set! host/session-epoch e)
(set! host/session-ctr 0)))))
(define host/-sess-next-sid (define host/-sess-next-sid
(fn () (fn ()
(let ((n (+ 1 (or (persist/backend-kv-get host/session-store host/-sess-counter-key) 0))))
(begin (begin
(persist/backend-kv-put host/session-store host/-sess-counter-key n) (set! host/session-ctr (+ host/session-ctr 1))
(str "s" n))))) (str "s" host/session-epoch "-" host/session-ctr))))
;; ── backend io fn: dispatch session/* ops onto the persist KV ─────── ;; ── backend io fn: dispatch session/* ops onto the persist KV ───────
;; LAZY: session/create mints a sid but writes NO row, so an anonymous request
;; (which never sets a field) leaves no durable trace — the store isn't spammed by
;; crawlers. The row appears on the first session/set (i.e. login), so a logged-in
;; session persists and survives a restart; session/exists is "has a written row".
(define host/session-backend (define host/session-backend
(fn (op) (fn (op)
(let ((kind (get op :op))) (let ((kind (get op :op)))
(cond (cond
((= kind "session/create") ((= kind "session/create") (host/-sess-next-sid))
(let ((sid (host/-sess-next-sid)))
(begin
(persist/backend-kv-put host/session-store (host/-sess-key sid) {})
sid)))
((= kind "session/exists") ((= kind "session/exists")
(persist/backend-kv-has? host/session-store (host/-sess-key (get op :sid)))) (persist/backend-kv-has? host/session-store (host/-sess-key (get op :sid))))
((= kind "session/get") ((= kind "session/get")

View File

@@ -126,6 +126,18 @@
(dream-status (host-se-secure host-se-live-cookie))) (dream-status (host-se-secure host-se-live-cookie)))
401) 401)
;; ── lazy persistence: only a written (logged-in) session leaves a durable row ──
(host-se-test "session/create writes no row (anonymous leaves no durable trace)"
(host/session-backend {:op "session/exists" :sid (host/session-backend {:op "session/create"})})
false)
(host-se-test "session/set creates the row (a login persists)"
(let ((sid (host/session-backend {:op "session/create"})))
(begin
(host/session-backend {:op "session/set" :sid sid :key :principal :val "bob"})
(list (host/session-backend {:op "session/exists" :sid sid})
(host/session-backend {:op "session/get" :sid sid :key :principal}))))
(list true "bob"))
(define host-se-tests-run! (define host-se-tests-run!
(fn () (fn ()
{:total (+ host-se-pass host-se-fail) {:total (+ host-se-pass host-se-fail)