Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apple/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apple/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ let package = Package(
.library(name: "LogseqModel", type: .dynamic, targets: ["LogseqModel"]),
],
dependencies: [
.package(url: "ssh://git@github.com/logseq/lui.git", revision: "a11edfe763e9a131e72a8e29e1dcbdd07f8d6427")
.package(url: "ssh://git@github.com/logseq/lui.git", revision: "ca87d1cdf534954a38f55616320ede8b7ac6d91e")
],
targets: [
.executableTarget(
Expand Down
37 changes: 37 additions & 0 deletions docs/agent-guide/011-query-block-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,43 @@ The full core test alias checks regressions in synchronization, projection, hydr
- Surface unsupported custom Hiccup views explicitly; do not execute browser rendering code.
- Preserve pre-existing workspace edits and avoid unrelated changes.

### Deferred-result projection identity

Cached result views include an optional integer `projectionRevision` in both
snapshot views and `resultRows.view`. It changes when the session's immutable
database identity or local journal day changes, even if `revision` (the database
transaction number), the first 12 rows, and the view configuration are unchanged.
For example, replacing a far-row projection may change
`{"revision": 100, "projectionRevision": 7}` to
`{"revision": 100, "projectionRevision": 8}`.

Clients must match both revisions, the input, and the graph before reusing a row
window. During same-query revalidation, the previous page may stay visible but
cannot satisfy the new generation's cache. The initial read remains 12 rows and
deferred reads remain bounded to 64. The token is session-local: compare it for
equality, never persist it or compare it across recreated sessions. The field is
additive; uncached renders and older cores may omit it, in which case clients
retain the legacy transaction-revision check. Older clients can ignore the field.
`Result_view.render_rows` accepts an optional `~cache` without changing existing
uncached callers; query snapshots pass the same session cache as `resultRows`
reads so both paths share the projection token. Supplied snapshot rows remain
authoritative and are not reused from the prepared-view cache: they can depend
on the active page as well as the database.

Retained rows still count toward presentation coverage, not cache freshness:
a partial fresh reply must not displace a more complete visible page. Compare
coverage within the replacement's row count so shrinking/empty results can
remove obsolete rows. Keep the requested range covering the current viewport,
including when it moves back inside a retained page during an outstanding read.

For opt-in release diagnostics, set `LOGSEQ_TRACE_RESULT_WINDOWS=1` at launch
(`SIMCTL_CHILD_LOGSEQ_TRACE_RESULT_WINDOWS=1` with `simctl`). Stderr records
`init`, `source`, `request`, `accept`/`accept-reuse`, and `reset` with the view UUID,
generation, transaction/projection revisions, inclusive viewport endpoints,
requested offset/limit, and row offset/length. It never records row content or
search text. These are shared page-state events, not native mount completion or
render-latency measurements. Logging is disabled by default.

## Question

No user input is required to begin.
Expand Down
2 changes: 1 addition & 1 deletion logseq.opam
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pin-depends: [
["melange-transit-core.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"]
["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"]
["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"]
["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#a11edfe763e9a131e72a8e29e1dcbdd07f8d6427"]
["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#ca87d1cdf534954a38f55616320ede8b7ac6d91e"]
["drive.dev" "git+https://github.com/logseq/drive.git#dd735a65ed525d1c36db38ac8e8114aa81d314a1"]
["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"]
["mldoc.dev" "git+https://github.com/logseq/mldoc.git#f11230c367964af2b575524d00779cfb1ffc7147"]
Expand Down
19 changes: 12 additions & 7 deletions shared/src/logseq/core/result_view.ml
Original file line number Diff line number Diff line change
Expand Up @@ -287,23 +287,26 @@ type prepared_view = {
}
type cache = {
mutable database : (Ds.db * int) option;
mutable projection_revision : int;
mutable scopes : ((string * string) * Query_block.row list) list;
mutable views : ((string * string * string * bool * string) * prepared_view) list;
}
let create_cache () = {database = None; scopes = []; views = []}
let create_cache () = {database = None; projection_revision = 0; scopes = []; views = []}
let validate_cache db cache =
let day = Cache_model.journal_day_for_ms (int_of_float (Unix.gettimeofday () *. 1000.)) in
match cache.database with
| Some (previous, previous_day) when previous == db && previous_day = day -> ()
| _ -> cache.database <- Some (db, day); cache.scopes <- []; cache.views <- []
| _ -> cache.database <- Some (db, day);
cache.projection_revision <- cache.projection_revision + 1;
cache.scopes <- []; cache.views <- []
let initial_window = {view_uuid = None; offset = 0; limit = 12}
let slice offset limit rows =
let rec drop offset rows = if offset <= 0 then rows else match rows with [] -> [] | _ :: rest -> drop (offset - 1) rest in
let rec take limit acc = function
| _ when limit <= 0 -> List.rev acc | [] -> List.rev acc
| row :: rest -> take (limit - 1) (row :: acc) rest in
take limit [] (drop offset rows)
let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db ~owner ~feature ~encode_block rows =
let render_rows_cached ?cache ~reuse_prepared ?(input = "") ?(include_values = true) ?window db ~owner ~feature ~encode_block rows =
Option.iter (validate_cache db) cache;
let views = saved_views db owner feature in
let views = if views = [] then [None] else List.map Option.some views in
Expand Down Expand Up @@ -331,7 +334,8 @@ let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db
| Some "logseq.property.view/type.list" | None -> "list" | _ -> "invalid" in
let fields = ["uuid", `String id; "name", `String name; "type", `String typ;
"persisted", `Bool (view <> None); "sorting", plain (setting "logseq.property.table/sorting");
"filters", plain (setting "logseq.property.table/filters")] in
"filters", plain (setting "logseq.property.table/filters")] @
Option.fold ~none:[] ~some:(fun cache -> ["projectionRevision", `Int cache.projection_revision]) cache in
try
if typ = "invalid" then invalid_arg "Unsupported view type";
let prepare () =
Expand All @@ -353,6 +357,7 @@ let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db
catalog = `List (List.map (column (typ <> "list")) catalog)} in
let prepared = match cache with
| None -> prepare ()
| Some _ when not reuse_prepared -> prepare ()
| Some cache ->
let key = owner, feature, input, include_values, id in
match List.assoc_opt key cache.views with
Expand All @@ -375,8 +380,8 @@ let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db
"rows", `List []; "columns", `List []]) in
`Assoc ["ownerUuid", `String owner; "featureType", `String feature; "views", `List (List.map view_json views)]

let render_rows ?input ?include_values ?window db ~owner ~feature ~encode_block rows =
render_rows_cached ?input ?include_values ?window db ~owner ~feature ~encode_block rows
let render_rows ?cache ?input ?include_values ?window db ~owner ~feature ~encode_block rows =
render_rows_cached ?cache ~reuse_prepared:false ?input ?include_values ?window db ~owner ~feature ~encode_block rows

let scope db owner feature = match feature with
| "query-result" -> (match Query_block.render db owner with Some result -> result.rows | None -> invalid_arg "This object has no query")
Expand All @@ -402,7 +407,7 @@ let render ?cache ?input ?include_values ?window db ~owner ~feature ~encode_bloc
| Some rows -> rows
| None -> let rows = scope db owner feature in
cache.scopes <- slice 0 8 ((key, rows) :: cache.scopes); rows in
Ok (render_rows_cached ?cache ?input ?include_values ?window db ~owner ~feature ~encode_block rows)
Ok (render_rows_cached ?cache ~reuse_prepared:true ?input ?include_values ?window db ~owner ~feature ~encode_block rows)
with error -> Error (match error with Invalid_argument message -> message | _ -> Printexc.to_string error)

let filter_values db ~owner ~feature ~attr =
Expand Down
2 changes: 1 addition & 1 deletion shared/src/logseq/core/result_view.mli
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ type cache
val create_cache : unit -> cache
val initial_window : row_window
val render_rows :
?input:string -> ?include_values:bool -> ?window:row_window -> Datascript.db -> owner:string -> feature:string ->
?cache:cache -> ?input:string -> ?include_values:bool -> ?window:row_window -> Datascript.db -> owner:string -> feature:string ->
encode_block:(Cache_model.block -> Yojson.Basic.t) -> Query_block.row list -> Yojson.Basic.t

val render :
Expand Down
2 changes: 1 addition & 1 deletion shared/src/logseq/core/rpc_session.ml
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ let query_for session db uuid =
Query_block.render ?current_page db uuid
|> Option.map (fun result ->
try
let views = Result_view.render_rows ~include_values:false ~window:Result_view.initial_window db ~owner:uuid ~feature:"query-result" ~encode_block:encode_tree result.Query_block.rows in
let views = Result_view.render_rows ~cache:session.result_view_cache ~include_values:false ~window:Result_view.initial_window db ~owner:uuid ~feature:"query-result" ~encode_block:encode_tree result.Query_block.rows in
let preview = {result with Query_block.rows = List.of_seq (Seq.take 12 (List.to_seq result.rows))} in
(match Query_block.to_yojson encode_tree preview, views with
| `Assoc fields, `Assoc view_fields -> `Assoc (fields @ view_fields)
Expand Down
61 changes: 45 additions & 16 deletions shared/src/logseq/view_query.ml
Original file line number Diff line number Diff line change
Expand Up @@ -721,35 +721,56 @@ let loaded_rows context ~owner ~feature view_source input model_source send =
let retry_count = Signal.state context.ui_scheduler 0 in
let page = Signal.state context.ui_scheduler initial in
let loading = Signal.state context.ui_scheduler false and error = Signal.state context.ui_scheduler "" in
let previous = ref initial and generation = ref 0 and requested = ref None in
let previous = ref initial and generation = ref 0 and page_generation = ref 0 and requested = ref None in
let previous_input = ref (Signal.sample input) in
let viewport = ref (0, 11) in
let trace_enabled = Sys.getenv_opt "LOGSEQ_TRACE_RESULT_WINDOWS" = Some "1" in
let trace ?requested_range phase data = if trace_enabled then (
let first, last = !viewport in
let offset, limit = match requested_range with Some range -> range | None -> Signal.sample (Signal.value range) in
Printf.eprintf "result-window phase=%s view=%s generation=%d page-generation=%d revision=%s projection=%s viewport=%d:%d range=%d:%d rows=%d:%d\n%!"
phase (jtext (jfield "uuid" data)) !generation !page_generation
(Json.to_string (jfield "revision" data)) (Json.to_string (jfield "projectionRevision" data))
first last offset limit (jint (jfield "rowOffset" data)) (List.length (jlist (jfield "rows" data)))) in
let has_range data input offset limit =
let start = jint (jfield "rowOffset" data) and count = row_count data in
jtext (jfield "input" data) = input && start <= offset &&
start + List.length (jlist (jfield "rows" data)) >= min count (offset + limit) in
let visible_overlap data =
let first, last = !viewport in
let start = jint (jfield "rowOffset" data) in
max 0 (min (last + 1) (start + List.length (jlist (jfield "rows" data))) - max first start) in
let drive ((view, input), ((offset, limit), (model, _))) =
if jfield "rowsDeferred" view.data <> `Bool true then Signal.set page view else (
let reset = input <> !previous_input || jfield "sorting" view.data <> jfield "sorting" (!previous).data
|| jfield "filters" view.data <> jfield "filters" (!previous).data || view.typ <> (!previous).typ in
if reset then Signal.set range (0, 12);
if reset then (viewport := (0, 11); Signal.set range (0, 12));
previous_input := input;
if not (Json.equal view.data (!previous).data) then (
previous := view; incr generation; requested := None; Signal.set page view);
previous := view; incr generation; requested := None;
trace "source" view.data;
(* Keep the presented window during same-query revalidation. *)
if reset || initial_model.Model.selected_graph_id <> model.Model.selected_graph_id
|| row_count view.data <= fst !viewport || jfield "error" view.data <> `Null
then (page_generation := !generation; trace "reset" view.data; Signal.set page view));
let offset, limit = if reset then 0, 12 else offset, limit in
let generation_prefix = prefix ^ string_of_int !generation ^ ":" in
let key = generation_prefix ^ Json.to_string (`List [`String input; `Int offset; `Int limit]) in
let current = Signal.sample (Signal.value page) in
let overlap data =
if jtext (jfield "input" data) <> input
|| jfield "revision" data <> jfield "revision" view.data then 0
else
let first, last = !viewport in
let start = jint (jfield "rowOffset" data) in
max 0 (min (last + 1) (start + List.length (jlist (jfield "rows" data))) - max first start) in
let current_projection data = jtext (jfield "input" data) = input
&& jfield "revision" data = jfield "revision" view.data
&& jfield "projectionRevision" data = jfield "projectionRevision" view.data in
let overlap data = if current_projection data then visible_overlap data else 0 in
let presented_overlap data =
if jtext (jfield "input" current.data) <> input then 0 else
let first, last = !viewport in
let start = jint (jfield "rowOffset" current.data) in
max 0 (min (min (last + 1) (row_count data))
(start + List.length (jlist (jfield "rows" current.data))) - max first start) in
let candidate = match List.assoc_opt key model.Model.result_row_windows with
| Some raw -> (try Some (Json.from_string raw) with _ -> None)
| None when jfield "revision" current.data = jfield "revision" view.data
| Some raw -> (try let data = Json.from_string raw in
if current_projection data then Some data else None with _ -> None)
| None when !page_generation = !generation && current_projection current.data
&& has_range current.data input offset limit -> Some current.data
| None when has_range view.data input offset limit -> Some view.data
| _ -> None in
Expand All @@ -758,22 +779,27 @@ let loaded_rows context ~owner ~feature view_source input model_source send =
Signal.set error failure;
Signal.set loading (candidate = None && failure = "" && jlist (jfield "rows" current.data) = []);
match candidate with
| Some data -> if overlap data >= overlap current.data && not (Json.equal data current.data)
then Signal.set page {view with data}
| Some data -> if (row_count data = 0 || overlap data > 0 || visible_overlap current.data = 0)
&& overlap data >= presented_overlap data
then (page_generation := !generation;
if not (Json.equal data current.data) then (trace "accept" data; Signal.set page {view with data}))
| None ->
(match !requested with
| Some previous_key when String.starts_with ~prefix:generation_prefix previous_key ->
Option.iter (fun raw ->
try
let data = Json.from_string raw in
if overlap data > overlap current.data then Signal.set page {view with data}
let coverage = overlap data and presented = presented_overlap data in
if coverage > presented || !page_generation <> !generation && coverage > 0 && coverage >= presented then (
page_generation := !generation; trace "accept-reuse" data; Signal.set page {view with data})
with Yojson.Json_error _ -> ())
(List.assoc_opt previous_key model.result_row_windows)
| _ -> ());
let busy = List.exists (function Model.LoadResultRowsEffect (_, key, _) -> String.starts_with ~prefix key | _ -> false)
(model.pending_effects @ model.in_flight_effects) in
if not busy && !requested <> Some key then (
requested := Some key;
trace ~requested_range:(offset, limit) "request" view.data;
ignore (send (Model.LoadResultRows (key, Json.to_string (`Assoc ([
"key", `String key; "ownerUuid", `String owner; "featureType", `String feature; "viewUuid", `String view.id;
"input", `String input; "offset", `Int offset; "limit", `Int limit] @
Expand All @@ -783,14 +809,17 @@ let loaded_rows context ~owner ~feature view_source input model_source send =
(own (Signal.map2 (fun range model -> range, model) (Signal.value range)
(own (Signal.map2 (fun model retry -> model, retry) model_source (Signal.value retry_count)))))) in
ignore (Signal.own context.ui_scope (Signal.observe request drive));
trace "init" initial.data;
drive (Signal.sample request);
let visible columns first last =
if first >= 0 && last >= first then (
viewport := first, last;
let current = (Signal.sample (Signal.value page)).data in
let start = jint (jfield "rowOffset" current) and length = List.length (jlist (jfield "rows" current)) in
let offset, limit = Signal.sample (Signal.value range) in
let margin = max 8 (last - first + 1) in
if first < start + margin && start > 0 || last >= start + length - margin && start + length < row_count current then (
if first < offset || min last (row_count current - 1) >= offset + limit
|| first < start + margin && start > 0 || last >= start + length - margin && start + length < row_count current then (
let limit = max columns (64 / columns * columns) in
let maximum = (max 0 (row_count current - limit) + columns - 1) / columns * columns in
let offset = min maximum (max 0 (first - max 0 ((limit - (last - first + 1)) / 2))) in
Expand Down
Loading
Loading