host: typed relations — Phase 6 (schema validation) + post-page perf fix
Phase 6 — gradual schema validation made real:
- host/blog-type-schemas now carries a declarative schema (a list of
{:block :msg} required-element rules); "article" requires an h1.
- host/blog--all-tags / --schema-issues / host/blog-type-issues walk the parsed
content and report each missing required block; host/blog-type-valid? = no
issues. A type with no schema imposes nothing (gradual).
- seed an "article" type-post (article subtype-of type). edit-submit now lists
the specific schema issues on a 400 ("an article needs a heading"), so a post
that is-a article must satisfy it on save.
Post-page performance (the unresponsiveness): a post page was ~1s even with no
relations and no load — NOT CPU (render-page ~2ms, in-memory handler ~5ms) but
the DURABLE read path: host/blog--relation-blocks called host/blog-out/in, each
re-scanning the whole KV (host/blog-slugs + an all-edges scan), so a page did ~7
kv-keys performs deep in the call stack. Each durable perform routes through
cek_run_with_io and is costly there. Fixes:
- host/blog-out/in read DIRECT edges from the durable edge store (string scan),
not lib/relations (whose queries re-saturate the Datalog ruleset, ~seconds).
- host/blog--relation-blocks reads the KV key list ONCE and derives both the post
set and the edges in memory (host/blog--edges-for / --recs-slugs), one kv-keys
plus a host/blog-get per linked post. Post pages: ~1s -> ~0.02s (46x); live
11-135s -> ~0.15s. lib/relations stays for TRANSITIVE queries only.
- conformance timeout 300 -> 600s: the relations-heavy blog suite is CPU-bound
under shared-box contention and was tripping a false truncation at 300.
271/271 (blog 100). Verified live: post pages fast, Tags/Related/Tagged-with-this
render, schema rejection works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
203
lib/host/blog.sx
203
lib/host/blog.sx
@@ -175,13 +175,47 @@
|
||||
(map (fn (n) (substr (symbol->string n) 5))
|
||||
(filter (fn (n) (starts-with? (symbol->string n) "blog:")) nodes))))))
|
||||
|
||||
;; outgoing targets / incoming sources of `slug` under `kind`, as slug lists.
|
||||
;; DIRECT edges come from the durable edge store, NOT lib/relations: each relations
|
||||
;; query re-runs the (CEK-interpreted) ruleset — ~seconds even on a tiny graph —
|
||||
;; whereas the edge:<src>|<kind>|<dst> KV rows are a cheap string scan. lib/relations
|
||||
;; is reserved for TRANSITIVE queries (descendants/ancestors). The two are always
|
||||
;; in sync: host/blog-relate! writes both, and a plain blog edge has no derived
|
||||
;; effective edges, so KV == relations/children for direct lookups.
|
||||
(define host/blog--parse-edge-key
|
||||
(fn (k)
|
||||
(if (starts-with? k "edge:")
|
||||
(let ((body (substr k 5)))
|
||||
(let ((p1 (index-of body "|")))
|
||||
(if (< p1 0) nil
|
||||
(let ((rest (substr body (+ p1 1))))
|
||||
(let ((p2 (index-of rest "|")))
|
||||
(if (< p2 0) nil
|
||||
{:src (substr body 0 p1)
|
||||
:kind (substr rest 0 p2)
|
||||
:dst (substr rest (+ p2 1))}))))))
|
||||
nil)))
|
||||
(define host/blog--all-edges
|
||||
(fn ()
|
||||
(filter (fn (e) (not (nil? e)))
|
||||
(map host/blog--parse-edge-key (persist/backend-kv-keys host/blog-store)))))
|
||||
|
||||
;; outgoing targets / incoming sources of `slug` under `kind`, as existing slugs.
|
||||
(define host/blog-out
|
||||
(fn (slug kind)
|
||||
(host/blog--edge-slugs (relations/children (host/blog--node slug) (string->symbol kind)))))
|
||||
(let ((existing (host/blog-slugs)))
|
||||
(filter (fn (s) (contains? existing s))
|
||||
(reduce (fn (acc e)
|
||||
(if (and (= (get e :src) slug) (= (get e :kind) kind))
|
||||
(concat acc (list (get e :dst))) acc))
|
||||
(list) (host/blog--all-edges))))))
|
||||
(define host/blog-in
|
||||
(fn (slug kind)
|
||||
(host/blog--edge-slugs (relations/parents (host/blog--node slug) (string->symbol kind)))))
|
||||
(let ((existing (host/blog-slugs)))
|
||||
(filter (fn (s) (contains? existing s))
|
||||
(reduce (fn (acc e)
|
||||
(if (and (= (get e :dst) slug) (= (get e :kind) kind))
|
||||
(concat acc (list (get e :src))) acc))
|
||||
(list) (host/blog--all-edges))))))
|
||||
|
||||
;; back-compat: "related posts" is just the symmetric "related" kind.
|
||||
(define host/blog-related (fn (slug) (host/blog-out slug "related")))
|
||||
@@ -229,19 +263,45 @@
|
||||
(define host/blog-tags (fn (slug) (host/blog-out slug "tagged"))) ;; a post's tags
|
||||
(define host/blog-tagged-with (fn (tag) (host/blog-in tag "tagged"))) ;; posts with a tag
|
||||
|
||||
;; ── gradual validation seam ─────────────────────────────────────────
|
||||
;; A type-post optionally carries a schema: a predicate over content. The map is
|
||||
;; empty for now — validation is gradual, types accrue schemas later — but the
|
||||
;; hook lives here so enforcement is a one-line addition, never a retrofit. A post
|
||||
;; is type-valid when every schema implied by its types accepts the content; with
|
||||
;; no schemas this is vacuously true, so it costs nothing until a type opts in.
|
||||
(define host/blog-type-schemas {})
|
||||
;; ── gradual validation: declarative type schemas ───────────────────
|
||||
;; A type may carry a SCHEMA: a list of rules {:block <tag> :msg <why>}, each
|
||||
;; requiring the content to contain (anywhere) an element of that tag. A post is
|
||||
;; checked against the schema of every type it is-a; a type with no schema imposes
|
||||
;; nothing (gradual). Schemas are declarative data (not opaque predicates) so they
|
||||
;; yield a specific, human error — and could later be stored ON the type-post.
|
||||
(define host/blog-type-schemas
|
||||
{:article (list {:block "h1" :msg "an article needs a heading (h1)"})})
|
||||
(define host/blog-schema-of (fn (type-slug) (get host/blog-type-schemas type-slug)))
|
||||
(define host/blog-type-valid?
|
||||
|
||||
;; every element tag in a parsed content tree, recursively (the heads of nested
|
||||
;; lists) — so "requires h1" matches an h1 even inside an article/section wrapper.
|
||||
(define host/blog--all-tags
|
||||
(fn (tree)
|
||||
(if (and (= (type-of tree) "list") (> (len tree) 0))
|
||||
(concat (list (str (first tree)))
|
||||
(reduce (fn (acc c) (concat acc (host/blog--all-tags c))) (list) (rest tree)))
|
||||
(list))))
|
||||
|
||||
;; the :msg of each required :block a schema asks for but the content lacks.
|
||||
(define host/blog--schema-issues
|
||||
(fn (schema content)
|
||||
(let ((tags (host/blog--all-tags (parse-safe content))))
|
||||
(reduce
|
||||
(fn (acc rule)
|
||||
(if (contains? tags (get rule :block)) acc (concat acc (list (get rule :msg)))))
|
||||
(list) schema))))
|
||||
|
||||
;; all schema issues for a post = the union over every type it is-a that carries a
|
||||
;; schema. Empty = valid; vacuous (and cheap) when no type has a schema.
|
||||
(define host/blog-type-issues
|
||||
(fn (slug content)
|
||||
(every?
|
||||
(fn (t) (let ((s (host/blog-schema-of t))) (or (nil? s) (s content))))
|
||||
(host/blog-types-of slug))))
|
||||
(reduce
|
||||
(fn (acc t)
|
||||
(let ((s (host/blog-schema-of t)))
|
||||
(if s (concat acc (host/blog--schema-issues s content)) acc)))
|
||||
(list) (host/blog-types-of slug))))
|
||||
(define host/blog-type-valid?
|
||||
(fn (slug content) (= (len (host/blog-type-issues slug content)) 0)))
|
||||
|
||||
;; Seed the root type-posts: "type" (the root) and "tag" (a kind of type). Types
|
||||
;; ARE posts, so these are real posts that document themselves; tag subtype-of
|
||||
@@ -256,7 +316,13 @@
|
||||
(host/blog-seed! "tag" "Tag"
|
||||
"(article (h1 \"Tag\") (p \"A tag is a kind of type (tag subtype-of type), so anything that is-a tag is also a type. A post is tagged with a tag; a tag post documents the tag and lists what is tagged with it.\"))"
|
||||
"published")
|
||||
(host/blog-relate! "tag" "type" "subtype-of"))))
|
||||
(host/blog-relate! "tag" "type" "subtype-of")
|
||||
;; "article" — a type WITH a schema (requires a heading). Posts that is-a
|
||||
;; article are validated against it on save (gradual typing in action).
|
||||
(host/blog-seed! "article" "Article"
|
||||
"(article (h1 \"Article\") (p \"A kind of post that must have a heading. A post that is-a article is checked against this type's schema on save — gradual typing: declaring the type adds the requirement, and the next edit must satisfy it.\"))"
|
||||
"published")
|
||||
(host/blog-relate! "article" "type" "subtype-of"))))
|
||||
|
||||
;; ── relate picker (filterable, paginated candidate list) ────────────
|
||||
;; Candidates to relate `slug` to: every post except itself and ones already
|
||||
@@ -372,31 +438,53 @@
|
||||
(map (fn (n) (substr (symbol->string n) 5))
|
||||
(filter (fn (n) (starts-with? (symbol->string n) "blog:")) nodes))))))
|
||||
|
||||
;; ALL of a post's relation blocks, generated by ITERATING the registry: each
|
||||
;; kind contributes its outgoing block (label) and, if it has an inverse, its
|
||||
;; incoming block (inverse-label). Empty blocks are dropped. So adding a kind to
|
||||
;; the registry makes it render automatically — no handler edit. One kv-keys read
|
||||
;; up front; the relation lookups are in-memory. Returns a wrapper div, or "".
|
||||
;; The relation blocks shown on a POST page — a CURATED, fixed set: Related (out),
|
||||
;; Tags (out), Tagged-with-this (in). PERFORMANCE: read the KV key list ONCE and
|
||||
;; derive both the post set and the edges from it in memory, instead of letting
|
||||
;; each host/blog-out/in re-scan the store. Every durable read is a perform routed
|
||||
;; through cek_run_with_io (costly deep in the call stack), so the post page must
|
||||
;; minimise them — this does ONE kv-keys plus a host/blog-get per linked post.
|
||||
(define host/blog--post-relation-specs
|
||||
(list {:kind "related" :dir "out" :label "Related posts"}
|
||||
{:kind "tagged" :dir "out" :label "Tags"}
|
||||
{:kind "tagged" :dir "in" :label "Tagged with this"}))
|
||||
;; in-memory: the slug list (out: dst, in: src) for `slug` under `kind` from
|
||||
;; pre-parsed edges — no perform.
|
||||
(define host/blog--edges-for
|
||||
(fn (edges slug kind dir)
|
||||
(reduce
|
||||
(fn (acc e)
|
||||
(if (= (get e :kind) kind)
|
||||
(if (= dir "out")
|
||||
(if (= (get e :src) slug) (concat acc (list (get e :dst))) acc)
|
||||
(if (= (get e :dst) slug) (concat acc (list (get e :src))) acc))
|
||||
acc))
|
||||
(list) edges)))
|
||||
;; slug list -> {:slug :title} records (existence-filtered), one host/blog-get each.
|
||||
(define host/blog--recs-slugs
|
||||
(fn (existing slugs)
|
||||
(map (fn (s) {:slug s :title (get (host/blog-get s) :title)})
|
||||
(filter (fn (s) (contains? existing s)) slugs))))
|
||||
(define host/blog--relation-blocks
|
||||
(fn (slug)
|
||||
(let ((existing (host/blog-slugs))
|
||||
(node (host/blog--node slug)))
|
||||
(let ((blocks
|
||||
(reduce
|
||||
(fn (acc spec)
|
||||
(let ((k (string->symbol (get spec :kind))))
|
||||
(let ((out-b (host/blog--edges-block
|
||||
(host/blog--recs existing (relations/children node k))
|
||||
(get spec :label)))
|
||||
(in-b (if (get spec :inverse-label)
|
||||
(host/blog--edges-block
|
||||
(host/blog--recs existing (relations/parents node k))
|
||||
(get spec :inverse-label))
|
||||
"")))
|
||||
(concat acc (filter (fn (b) (not (= b ""))) (list out-b in-b))))))
|
||||
(list)
|
||||
host/blog-rel-kinds)))
|
||||
(if (> (len blocks) 0) (cons (quote div) blocks) "")))))
|
||||
(let ((keys (persist/backend-kv-keys host/blog-store))) ;; ONE durable read
|
||||
(let ((existing (reduce (fn (acc k)
|
||||
(if (starts-with? k "blog:")
|
||||
(concat acc (list (substr k 5))) acc))
|
||||
(list) keys))
|
||||
(edges (filter (fn (e) (not (nil? e)))
|
||||
(map host/blog--parse-edge-key keys))))
|
||||
(let ((blocks
|
||||
(reduce
|
||||
(fn (acc spec)
|
||||
(let ((b (host/blog--edges-block
|
||||
(host/blog--recs-slugs existing
|
||||
(host/blog--edges-for edges slug (get spec :kind) (get spec :dir)))
|
||||
(get spec :label))))
|
||||
(if (= b "") acc (concat acc (list b)))))
|
||||
(list)
|
||||
host/blog--post-relation-specs)))
|
||||
(if (> (len blocks) 0) (cons (quote div) blocks) ""))))))
|
||||
|
||||
;; the relation section for the post page: the blocks, or — when empty and the
|
||||
;; viewer is logged in — a subtle "add some" hint; nothing for anonymous viewers.
|
||||
@@ -777,26 +865,31 @@
|
||||
;; create paths (unparseable body -> 400, post left intact). Slug is preserved.
|
||||
(define host/blog-edit-submit
|
||||
(fn (req)
|
||||
(let ((slug (dream-param req "slug")))
|
||||
(let ((r (host/blog-get slug)))
|
||||
(if (nil? r)
|
||||
(dream-html-status 404
|
||||
(host/blog--page "Not found"
|
||||
(quasiquote (div (h1 "404") (p (unquote (str "No post: " slug)))))))
|
||||
(let ((title (or (dream-form-field req "title") (get r :title)))
|
||||
(sx-content (or (dream-form-field req "sx_content") ""))
|
||||
(status (or (dream-form-field req "status") (get r :status))))
|
||||
;; parse-valid AND type-valid (the post's types' schemas accept the
|
||||
;; content — vacuous until a type opts into a schema).
|
||||
(if (and (host/blog-content-ok? sx-content)
|
||||
(host/blog-type-valid? slug sx-content))
|
||||
(let ((slug (dream-param req "slug")) (r (host/blog-get (dream-param req "slug"))))
|
||||
(if (nil? r)
|
||||
(dream-html-status 404
|
||||
(host/blog--page "Not found"
|
||||
(quasiquote (div (h1 "404") (p (unquote (str "No post: " slug)))))))
|
||||
(let ((title (or (dream-form-field req "title") (get r :title)))
|
||||
(sx-content (or (dream-form-field req "sx_content") ""))
|
||||
(status (or (dream-form-field req "status") (get r :status))))
|
||||
;; collect issues up front (perform): unparseable markup, then each
|
||||
;; schema requirement the post's types impose. Empty = save.
|
||||
(let ((issues (if (host/blog-content-ok? sx-content)
|
||||
(host/blog-type-issues slug sx-content)
|
||||
(list "Post body is not valid SX markup."))))
|
||||
(if (= (len issues) 0)
|
||||
(begin
|
||||
(host/blog-put! slug title sx-content status)
|
||||
(dream-redirect (str "/" slug "/")))
|
||||
(dream-html-status 400
|
||||
(host/blog--page "Error"
|
||||
(quasiquote (div (h1 "Error") (p "Post body is not valid SX markup.")
|
||||
(p (a :href (unquote (str "/" slug "/edit")) "Back")))))))))))))
|
||||
(let ((issue-items (map (fn (i) (quasiquote (li (unquote i)))) issues)))
|
||||
(dream-html-status 400
|
||||
(host/blog--page "Cannot save"
|
||||
(quasiquote
|
||||
(div (h1 "Cannot save")
|
||||
(p "This post can't be saved yet:")
|
||||
(unquote (list (quote ul) issue-items))
|
||||
(p (a :href (unquote (str "/" slug "/edit")) "Back"))))))))))))))
|
||||
|
||||
;; ── routes ──────────────────────────────────────────────────────────
|
||||
;; Public reads + the create form. /, /posts, /new BEFORE /:slug (catch-all).
|
||||
|
||||
Reference in New Issue
Block a user