host: relations-as-posts slice 2.5 — picker title reads are O(page), not O(pool)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s

relate-candidates computes the available candidate SLUGS (slug-sorted, no per-candidate
read), then reads titles only for the page it returns. On the unfiltered path (q="" —
the initial picker load AND every editor server-fill, the common case) that's ~limit
durable reads instead of one-per-post, cutting the http-listen suspend/resume churn. A
filter (q≠"") still resolves titles across the pool since it matches on the title.

(A boot slug→title cache would make the filter O(1)-perform too, but it's blocked: no
bulk KV read, and a per-post host/blog-get loop at boot hits the JIT 'durable read in a
boot loop drops all-but-first' bug — see plans/relations-as-posts.md.)

conformance 291/291, run-picker-check 3/3 (incl. the title filter + paging).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 07:50:31 +00:00
parent 90190346aa
commit f94b9d0b93
2 changed files with 43 additions and 25 deletions

View File

@@ -418,24 +418,34 @@
(fn (kind other) (fn (kind other)
(contains? (host/blog--candidate-pool kind) other))) (contains? (host/blog--candidate-pool kind) other)))
(define host/blog--title (fn (s) (get (host/blog-get s) :title))) ;; one durable read
;; One PAGE of candidates (records {:slug :title}) for relating `slug` under `kind`.
;; Slice 2.5 — title reads are O(page), not O(pool): the available candidate SLUGS are
;; computed + slug-sorted with NO per-candidate read; then titles are fetched only for
;; the rows actually returned. On the unfiltered path (q="" — the initial picker load
;; AND every editor server-fill) that's ~`limit` reads instead of one-per-post, which
;; was the durable-read churn under http-listen. A filter (q≠"") still resolves titles
;; across the pool, since it matches on the title — but that's the interactive path.
(define host/blog--relate-candidates (define host/blog--relate-candidates
(fn (slug q kind) (fn (slug q kind offset limit)
(let ((spec (host/blog--kind-spec kind))) (let ((pool (host/blog--candidate-pool kind))
(let ((pool (host/blog--candidate-pool kind)) (already (host/blog-out slug kind))
(already (host/blog-out slug kind)) (ql (lower (or q ""))))
(ql (lower (or q "")))) (let ((avail (sort (filter (fn (s) (and (not (= s slug)) (not (contains? already s)))) pool))))
;; pool is slugs; resolve titles, drop self + already-linked, filter by q (if (= ql "")
(let ((cands ;; no filter: page by slug, then read titles for just the page
(filter (map (fn (s) {:slug s :title (host/blog--title s)})
(fn (p) (take (drop avail offset) limit))
(or (= ql "") ;; filter: resolve titles, match on title|slug, then page
(contains? (lower (get p :title)) ql) (let ((recs (map (fn (s) {:slug s :title (host/blog--title s)}) avail)))
(contains? (get p :slug) ql))) (take
(map (fn (s) {:slug s :title (get (host/blog-get s) :title)}) (drop
(filter (fn (s) (and (not (= s slug)) (not (contains? already s)))) pool))))) (filter (fn (r) (or (contains? (lower (get r :title)) ql)
;; title-sort via [title slug] pairs (sort compares the title first) (contains? (get r :slug) ql)))
(map (fn (pair) {:slug (nth pair 1) :title (nth pair 0)}) recs)
(sort (map (fn (p) (list (get p :title) (get p :slug))) cands)))))))) offset)
limit)))))))
;; One candidate row: a tiny form whose button adds the relation under `kind`. ;; One candidate row: a tiny form whose button adds the relation under `kind`.
(define host/blog--picker-item (define host/blog--picker-item
@@ -492,8 +502,7 @@
;; so a filter like "Item 13" arrives as "Item%2013" — decode it. ;; so a filter like "Item 13" arrives as "Item%2013" — decode it.
(q (dr/url-decode (or (dream-query-param req "q") ""))) (q (dr/url-decode (or (dream-query-param req "q") "")))
(offset (host/query-int req "offset" 0))) (offset (host/query-int req "offset" 0)))
(let ((page (take (drop (host/blog--relate-candidates slug q kind) offset) (let ((page (host/blog--relate-candidates slug q kind offset host/blog--picker-limit)))
host/blog--picker-limit)))
(let ((rows (join "" (map (fn (p) (render-page (host/blog--picker-item slug p kind))) page))) (let ((rows (join "" (map (fn (p) (render-page (host/blog--picker-item slug p kind))) page)))
(more (if (= (len page) host/blog--picker-limit) (more (if (= (len page) host/blog--picker-limit)
(render-page (host/blog--picker-more slug kind q (+ offset host/blog--picker-limit))) (render-page (host/blog--picker-more slug kind q (+ offset host/blog--picker-limit)))
@@ -686,8 +695,7 @@
;; li-trees splice in as children (component args would evaluate them). ;; li-trees splice in as children (component args would evaluate them).
(results-ul (results-ul
(let ((rows (if with-cands (let ((rows (if with-cands
(let ((cands (take (host/blog--relate-candidates slug "" kind) (let ((cands (host/blog--relate-candidates slug "" kind 0 host/blog--picker-limit)))
host/blog--picker-limit)))
(append (append
(map (fn (p) (host/blog--picker-item slug p kind)) cands) (map (fn (p) (host/blog--picker-item slug p kind)) cands)
(if (= (len cands) host/blog--picker-limit) (if (= (len cands) host/blog--picker-limit)

View File

@@ -58,10 +58,20 @@ the relation's object-end declaration from the anchor**, which includes the root
`host/blog-rel-kinds` is a VALUE the boot populates and the cache loads are UNROLLED. `host/blog-rel-kinds` is a VALUE the boot populates and the cache loads are UNROLLED.
**Conformance green ≠ correct live — verify the rendered edit page.** (Re-fold the **Conformance green ≠ correct live — verify the rendered edit page.** (Re-fold the
enumeration once plans/jit-bytecode-correctness.md lands.) enumeration once plans/jit-bytecode-correctness.md lands.)
- **Follow-up (Slice 2.5):** `relate-candidates` does a `host/blog-get` per pool member ### Slice 2.5 — picker title reads are O(page), not O(pool) — DONE
(O(posts) for `related`). A boot-time **title cache** (updated on put!/delete!) would make - `relate-candidates` computes the available candidate SLUGS (slug-sorted, no per-candidate
the picker O(1)-perform and cut the suspend/resume churn. Subject-end declarations + a read), then reads titles ONLY for the page it returns. On the unfiltered path (q="" — the
proper relation-subtype closure (when relations get subtyped) also belong here. initial picker load AND every editor server-fill, the common case) that's ~`limit` reads
instead of one-per-post — killing the durable-read churn under http-listen. A filter
(q≠"") still resolves titles across the pool (it matches on the title), but that's the
interactive path.
- A boot-time slug→title **cache** would make even the filter O(1)-perform, BUT it's blocked
for now: there's no bulk KV read, and a per-post `host/blog-get` loop **at boot** hits the
JIT bug (a durable read inside a boot loop drops all-but-first — `load-edges!` only works
because its loop body is perform-free). Revisit with a bulk read or once the JIT lands.
**Remaining follow-ups:** subject-end declarations (who may be the *source*); a proper
relation-subtype closure when relations get subtyped; the boot title cache above.
### Slice 3 — typed relations (target-type constraints) — DONE ### Slice 3 — typed relations (target-type constraints) — DONE
- The declaration's `declares`-anchor IS the target-type constraint: `is-a`/`subtype-of` - The declaration's `declares`-anchor IS the target-type constraint: `is-a`/`subtype-of`