diff --git a/Project.toml b/Project.toml index 8fbe224..83bf6af 100644 --- a/Project.toml +++ b/Project.toml @@ -9,6 +9,7 @@ Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" BetterFileWatching = "c9fd44ac-77b5-486c-9482-9798bd063cc6" Configurations = "5218b696-f38b-4ac9-8b61-a12ec717816d" Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" FromFile = "ff7dd447-1dcb-4ce3-b8ac-22a812192de7" Git = "d7ba0133-e1db-5d97-8f8c-041e4b3a1eb2" GitHubActions = "6b79fd1a-b13a-48ab-b6b0-aaee1fee41df" @@ -16,10 +17,14 @@ Glob = "c27321d9-0574-5035-807b-f59d2c89b15c" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" +OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" Pluto = "c3e4b0f8-55cb-11ea-2926-15256bba5781" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" +Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" TerminalLoggers = "5d786b92-1e48-4d6f-9151-6b4477ca9bed" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" @@ -29,18 +34,21 @@ AbstractPlutoDingetjes = "1.1" BetterFileWatching = "^0.1.2" Configurations = "0.16, 0.17" FromFile = "0.1" +Distributions = "0.25" Git = "1" GitHubActions = "0.1" Glob = "1" HTTP = "^1.0.2" +OrderedCollections = "1" JSON = "0.21" Pluto = "0.19.18" TerminalLoggers = "0.1" julia = "1.6" [extras] -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +Deno_jll = "04572ae6-984a-583e-9378-9577a1c2574d" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" [targets] -test = ["Test", "Random"] +test = ["Deno_jll", "Test", "Random"] diff --git a/src/Actions.jl b/src/Actions.jl index a9a8fd2..9dd5aac 100644 --- a/src/Actions.jl +++ b/src/Actions.jl @@ -1,12 +1,15 @@ -import Pluto: Pluto, without_pluto_file_extension, generate_html, @asynclog +import Pluto: + Pluto, without_pluto_file_extension, generate_html, @asynclog, withtoken, Firebasey using Base64 using FromFile +import HTTP.URIs @from "./MoreAnalysis.jl" import bound_variable_connections_graph @from "./Export.jl" import try_get_exact_pluto_version, try_fromcache, try_tocache @from "./Types.jl" import NotebookSession, RunningNotebook, FinishedNotebook, RunResult @from "./Configuration.jl" import PlutoDeploySettings, is_glob_match @from "./FileHelpers.jl" import find_notebook_files_recursive +@from "./precomputed/index.jl" import generate_precomputed_staterequests @from "./PlutoHash.jl" import plutohash @@ -64,8 +67,12 @@ function process( end keep_running = - settings.SliderServer.enabled && !is_glob_match(path, settings.SliderServer.exclude) - skip_cache = keep_running || is_glob_match(path, settings.Export.ignore_cache) + (settings.SliderServer.enabled || settings.Precompute.enabled) && + !is_glob_match(path, settings.SliderServer.exclude) + skip_cache = + keep_running || + is_glob_match(path, settings.Export.ignore_cache) || + path ∈ settings.Export.ignore_cache cached_state = skip_cache ? nothing : try_fromcache(settings.Export.cache_dir, new_hash) @@ -115,9 +122,25 @@ function process( ) end + new_session = NotebookSession(; + path=s.path, + current_hash=new_hash, + desired_hash=s.desired_hash, + run=run, + ) + if settings.Precompute.enabled + generate_precomputed_staterequests( + new_session; + settings, + pluto_session=server_session, + output_dir, + ) + # TODO shutdown + end + @info "### ✓ $(progress) Ready" s.path new_hash - NotebookSession(; path, current_hash=new_hash, desired_hash=s.desired_hash, run) + new_session end ### @@ -205,7 +228,11 @@ function generate_static_export( slider_server_running_somewhere = settings.Export.slider_server_url !== nothing || - (settings.SliderServer.serve_static_export_folder && settings.SliderServer.enabled) + ( + settings.SliderServer.serve_static_export_folder && + settings.SliderServer.enabled + ) || + settings.Precompute.enabled notebookfile_js = if settings.Export.offer_binder || slider_server_running_somewhere if settings.Export.baked_notebookfile @@ -300,4 +327,7 @@ function remove_static_export(path; settings, output_dir) !settings.Export.baked_notebookfile tryrm(export_jl_path) end -end \ No newline at end of file + + + +end diff --git a/src/Configuration.jl b/src/Configuration.jl index 1abf873..6228cff 100644 --- a/src/Configuration.jl +++ b/src/Configuration.jl @@ -1,7 +1,8 @@ using Configurations import TOML import Pluto -export SliderServerSettings, ExportSettings, PlutoDeploySettings, get_configuration +export SliderServerSettings, + ExportSettings, PrecomputeSettings, PlutoDeploySettings, get_configuration using TerminalLoggers: TerminalLogger using Logging: global_logger using FromFile @@ -24,6 +25,17 @@ import Glob simulated_lag::Real = 0 end + +@extract_docs @option struct PrecomputeSettings + "Precompute slider server requests?" + enabled::Bool = false + "List of notebook files to skip precomputation. Provide paths relative to `start_dir`." + exclude::Vector{String} = String[] + "Whether or not to partially precompute notebooks. If `true`, notebooks will only be precomputed if **all** their sliders can be precomputed" + only_fully::Bool = false + max_filesize_per_group::Integer = 1_000_000 +end + @extract_docs @option struct ExportSettings "Generate static HTML files? This setting can only be `false` if you are also running a slider server." enabled::Bool = true @@ -57,6 +69,7 @@ end @option struct PlutoDeploySettings SliderServer::SliderServerSettings = SliderServerSettings() Export::ExportSettings = ExportSettings() + Precompute::PrecomputeSettings = PrecomputeSettings() Pluto::Pluto.Configuration.Options = Pluto.Configuration.Options() end diff --git a/src/HTTPRouter.jl b/src/HTTPRouter.jl index fc5478c..71b1511 100644 --- a/src/HTTPRouter.jl +++ b/src/HTTPRouter.jl @@ -12,6 +12,7 @@ using HTTP using Sockets import JSON +@from "./run_bonds.jl" import run_bonds_get_patches @from "./IndexJSON.jl" import generate_index_json @from "./IndexHTML.jl" import temp_index, generate_basic_index_html @from "./Types.jl" import NotebookSession, RunningNotebook @@ -35,7 +36,7 @@ function make_router( ) router = HTTP.Router() - function get_sesh(request::HTTP.Request) + function get_sesh(request::HTTP.Request)::Union{Nothing,NotebookSession} uri = HTTP.URI(request.target) parts = HTTP.URIs.splitpath(uri.path) @@ -62,7 +63,7 @@ function make_router( end end - function get_bonds(request::HTTP.Request) + function get_bonds(request::HTTP.Request)::Dict{Symbol,Any} request_body = if request.method == "POST" IOBuffer(HTTP.payload(request)) elseif request.method == "GET" @@ -104,67 +105,17 @@ function make_router( lag > 0 && sleep(lag) end - topological_order, new_state = withtoken(sesh.run.token) do - try - notebook.bonds = bonds + ## + result = run_bonds_get_patches(server_session, sesh.run, bonds) + ## - names::Vector{Symbol} = Symbol.(keys(bonds)) - - topological_order = Pluto.set_bond_values_reactive( - session=server_session, - notebook=notebook, - bound_sym_names=names, - is_first_values=[false for _n in names], # because requests should be stateless. We might want to do something special for the (actual) initial request (containing every initial bond value) in the future. - run_async=false, - )::Pluto.TopologicalOrder - - new_state = Pluto.notebook_to_js(notebook) - - topological_order, new_state - catch e - @error "Failed to set bond values" exception = (e, catch_backtrace()) - nothing, nothing - end - end - topological_order === nothing && return ( + if result === nothing HTTP.Response(500, "Failed to set bond values") |> with_cors! |> with_not_cacheable! - ) - - ids_of_cells_that_ran = [c.cell_id for c in topological_order.runnable] - - @debug "Finished running!" length(ids_of_cells_that_ran) - - # We only want to send state updates about... - function only_relevant(state) - new = copy(state) - # ... the cells that just ran and ... - new["cell_results"] = filter(state["cell_results"]) do (id, cell_state) - id ∈ ids_of_cells_that_ran - end - # ... nothing about bond values, because we don't want to synchronize among clients. and... - delete!(new, "bonds") - # ... we ignore changes to the status tree caused by a running bonds. - delete!(new, "status_tree") - new end - patches = Firebasey.diff( - only_relevant(sesh.run.original_state), - only_relevant(new_state), - ) - patches_as_dicts::Array{Dict} = Firebasey._convert(Array{Dict}, patches) - - HTTP.Response( - 200, - Pluto.pack( - Dict{String,Any}( - "patches" => patches_as_dicts, - "ids_of_cells_that_ran" => ids_of_cells_that_ran, - ), - ), - ) |> + HTTP.Response(200, Pluto.pack(result)) |> with_cacheable! |> with_cors! |> with_msgpack! diff --git a/src/PlutoSliderServer.jl b/src/PlutoSliderServer.jl index fab0498..1d8312d 100644 --- a/src/PlutoSliderServer.jl +++ b/src/PlutoSliderServer.jl @@ -17,6 +17,7 @@ using FromFile @from "./ReloadFolder.jl" import update_sessions!, select @from "./HTTPRouter.jl" import make_router, ReferrerMiddleware @from "./gitpull.jl" import fetch_pull +@from "./precomputed/debug.jl" import start_debugging @from "./PlutoHash.jl" import plutohash, base64urlencode, base64urldecode export plutohash, base64urlencode, base64urldecode @@ -528,4 +529,5 @@ function kind_of_debounced(f) end + end diff --git a/src/precomputed/debug.jl b/src/precomputed/debug.jl new file mode 100644 index 0000000..a4b1ca4 --- /dev/null +++ b/src/precomputed/debug.jl @@ -0,0 +1,62 @@ + +# this file is included in src/PlutoSliderServer.jl +# yolo + +import Pluto: Pluto, @asynclog, tamepath, is_pluto_notebook +import Configurations +using FromFile + +@from "./index.jl" import variable_groups, generate_precomputed_staterequests_report +@from "../Types.jl" import RunningNotebook +@from "../Configuration.jl" import PlutoDeploySettings +@from "../MoreAnalysis.jl" import bound_variable_connections_graph + + +function start_debugging(notebook_path::String; kwargs...) + notebook_path = tamepath(notebook_path) + @assert is_pluto_notebook(notebook_path) + + settings = Configurations.from_kwargs(PlutoDeploySettings; kwargs...) + + + @info "Running notebook..." + + pluto_session = Pluto.ServerSession(; options=settings.Pluto) + notebook = Pluto.SessionActions.open(pluto_session, notebook_path; run_async=false) + + @info "Notebook ready! Starting server..." + + pluto_session.options.server.show_file_system = false + t = @asynclog Pluto.run(pluto_session) + sleep(1) + + repeat = true + while repeat + connections = bound_variable_connections_graph(notebook) + + run = RunningNotebook(; + path=notebook_path, + notebook=notebook, + bond_connections=connections, + original_state=Pluto.notebook_to_js(notebook), + ) + + groups = variable_groups(connections; pluto_session, notebook=run.notebook) + + report = + generate_precomputed_staterequests_report(groups, run; settings, pluto_session) + + for _ = 1:first(displaysize(stdout)) + println(stdout) + end + show(stdout, MIME"text/plain"(), report) + println(stdout) + println(stdout) + + + repeat = Base.prompt("Run again? (y/n)"; default="y") == "y" + end + + wait(t) + +end \ No newline at end of file diff --git a/src/precomputed/index.jl b/src/precomputed/index.jl new file mode 100644 index 0000000..4a64a8f --- /dev/null +++ b/src/precomputed/index.jl @@ -0,0 +1,188 @@ +import Pluto +using Base64 +using OrderedCollections +using FromFile +import HTTP.URIs +import Random +import Statistics +using Distributions +import Markdown + +# @from "../MoreAnalysis.jl" import bound_variable_connections_graph +@from "../Types.jl" import NotebookSession, RunningNotebook +@from "../Configuration.jl" import PlutoDeploySettings +@from "../run_bonds.jl" import run_bonds_get_patches +@from "../PlutoHash.jl" import base64urlencode + +@from "./types.jl" import VariableGroupPossibilities, PrecomputedSampleReport, Reason + + +function variable_groups( + connections; + pluto_session::Pluto.ServerSession, + notebook::Pluto.Notebook, +)::Vector{VariableGroupPossibilities} + VariableGroupPossibilities[ + let + names = sort(variable_group) + + not_available = Dict{Symbol,Reason}() + + possible_values = [ + let + result = try + Pluto.possible_bond_values( + pluto_session::Pluto.ServerSession, + notebook::Pluto.Notebook, + n::Symbol, + ) + catch e + Symbol("Failed ", string(e)) + end + if result isa Symbol + # @error "Failed to get possible values for $(n)" result + not_available[n] = result + [] + else + result + end + end for n in names + ] + + VariableGroupPossibilities(; + names=names, + possible_values=possible_values, + not_available=not_available, + num_possibilities=prod(BigInt.(length.(possible_values))), + ) + end for variable_group in filter(!isempty, Set(values(connections))) + ] +end + + +function combination_iterator(group::VariableGroupPossibilities) + Iterators.map(Iterators.product(group.possible_values...)) do combination + bonds_dict = OrderedDict{Symbol,Any}( + n => OrderedDict{String,Any}("value" => v) for + (n, v) in zip(group.names, combination) + ) + + return (combination, bonds_dict) + end +end + +biglength(pr::Iterators.ProductIterator) = prod(BigInt[biglength(i) for i in pr.iterators]) +biglength(g::Base.Generator) = biglength(g.iter) +biglength(x) = BigInt(length(x)) + +function generate_precomputed_staterequests_report( + groups::Vector{VariableGroupPossibilities}, + run::RunningNotebook; + settings::PlutoDeploySettings, + pluto_session::Pluto.ServerSession, +)::PrecomputedSampleReport + map(groups) do group + stat = if !isempty(group.not_available) + Normal(0.0, 0.0) + else + iterator = combination_iterator(group) + if isempty(iterator) || isempty(group.names) + Normal(0.0, 0.0) + else + file_size_sample = + map( + rand(iterator, length(group.names) * 3), + ) do (combination, bonds_dict) + + result = run_bonds_get_patches(pluto_session, run, bonds_dict) + + if result !== nothing + length(Pluto.pack(result)) + else + 0 + end |> BigInt + end .* biglength(iterator) # multiply by number of combinations to get an estimate of the total file size + + fit(Normal, file_size_sample) + end + end + VariableGroupPossibilities(; + names=group.names, + possible_values=group.possible_values, + num_possibilities=group.num_possibilities, + not_available=group.not_available, + file_size_sample_distribution=stat, + settings, + ) + end |> PrecomputedSampleReport +end + + +function generate_precomputed_staterequests( + notebook_session::NotebookSession; + settings::PlutoDeploySettings, + pluto_session::Pluto.ServerSession, + output_dir=".", +) + + sesh = notebook_session + run = sesh.run + connections = run.bond_connections + current_hash = sesh.current_hash + + @assert run isa RunningNotebook + + mkpath(joinpath(output_dir, "bondconnections")) + mkpath(joinpath(output_dir, "staterequest", URIs.escapeuri(current_hash))) + + bondconnections_path = + joinpath(output_dir, "bondconnections", URIs.escapeuri(current_hash)) + write(bondconnections_path, Pluto.pack(run.bond_connections)) + @debug "Written bond connections to " bondconnections_path + + unanalyzed_groups = variable_groups(connections; pluto_session, notebook=run.notebook) + + report = generate_precomputed_staterequests_report( + unanalyzed_groups, + run; + settings, + pluto_session, + ) + groups = report.groups + + if report.judgement.should_precompute_all + @info "Notebook can be fully precomputed!" report + elseif settings.Precompute.only_fully + @warn "Notebook cannot be fully precomputed - skipping it!" report + return + else + @warn "Notebook cannot be (fully) precomputed" report + end + + foreach(groups) do group::VariableGroupPossibilities + if group.judgement.should_precompute_all + for (combination, bonds_dict) in combination_iterator(group) + filename = Pluto.pack(bonds_dict) |> base64urlencode + if length(filename) > 255 + @warn "Filename is too long, stopping this group" group.names + break + end + result = run_bonds_get_patches(pluto_session, run, bonds_dict) + + if result !== nothing + write_path = joinpath( + output_dir, + "staterequest", + URIs.escapeuri(current_hash), + filename, + ) + + write(write_path, Pluto.pack(result)) + + @debug "Written state request to " write_path values = + (; (zip(group.names, combination))...) + end + end + end + end +end diff --git a/src/precomputed/types.jl b/src/precomputed/types.jl new file mode 100644 index 0000000..05e6e39 --- /dev/null +++ b/src/precomputed/types.jl @@ -0,0 +1,211 @@ + +using FromFile +import Random +import Statistics +using Distributions +import Markdown + +@from "../Configuration.jl" import PlutoDeploySettings + +const Reason = Symbol + +Base.@kwdef struct Judgement + should_precompute_all::Bool = false + not_available::Bool = false + close_to_filesize_limit::Bool = false + exceeds_filesize_limit::Bool = false +end + +struct VariableGroupPossibilities + names::Vector{Symbol} + possible_values::Vector + not_available::Dict{Symbol,Reason} + # size info: + file_size_sample_distribution::Union{Nothing,Distribution} + num_possibilities::BigInt + + judgement::Judgement +end + +function VariableGroupPossibilities(; + names::Vector{Symbol}, + possible_values::Vector, + not_available::Dict{Symbol,Reason}, + # size info: + num_possibilities::BigInt, + file_size_sample_distribution::Union{Nothing,Distribution}=nothing, + settings::Union{Nothing,PlutoDeploySettings}=nothing, +) + is_not_available = !isempty(not_available) + + if !isa(file_size_sample_distribution, Nothing) + @assert settings isa PlutoDeploySettings + + limit = settings.Precompute.max_filesize_per_group + current = mean(file_size_sample_distribution) + + exceeds_filesize_limit = current > limit + close_to_filesize_limit = current > limit * 0.7 + else + exceeds_filesize_limit = close_to_filesize_limit = false + end + + j = Judgement(; + should_precompute_all=!is_not_available && !exceeds_filesize_limit, + exceeds_filesize_limit, + close_to_filesize_limit, + not_available=is_not_available, + ) + + VariableGroupPossibilities( + names, + possible_values, + not_available, + file_size_sample_distribution, + num_possibilities, + j, + ) +end + +Base.@kwdef struct PrecomputedSampleReport + groups::Vector{VariableGroupPossibilities} + # size info: + file_size_sample_distribution::Union{Nothing,Distribution} = nothing + num_possibilities::BigInt + judgement::Judgement +end + +function PrecomputedSampleReport(groups::Vector{VariableGroupPossibilities}) + num_possibilities = sum(BigInt[g.num_possibilities for g in groups]) + + file_size_sample_distribution = + map(groups) do group + group.file_size_sample_distribution + end |> sum_distributions + + judgement = Judgement(; + should_precompute_all=all(g.judgement.should_precompute_all for g in groups), + not_available=any(g.judgement.not_available for g in groups), + exceeds_filesize_limit=any(g.judgement.exceeds_filesize_limit for g in groups), + close_to_filesize_limit=any(g.judgement.close_to_filesize_limit for g in groups), + ) + + PrecomputedSampleReport(; + groups, + num_possibilities, + file_size_sample_distribution, + judgement, + ) +end + +function exceeds_limit(j::Judgement, prefix::String="") + + j.exceeds_filesize_limit ? "*($(prefix)exceeding filesize limit)*" : + j.close_to_filesize_limit ? "*($(prefix)close to filesize limit)*" : "" +end + + +function Base.show(io::IO, m::MIME"text/plain", p::PrecomputedSampleReport) + groups = sort(p.groups; by=g -> mean(g.file_size_sample_distribution), rev=true) + + r = Markdown.parse( + """ +# Precomputed state summary + +Total size estimate (based on a radom sample): $(p.num_possibilities) files, $( + p.file_size_sample_distribution |> format_filesize +) $(exceeds_limit(p.judgement, "some groups are ")) + +$(map(groups) do group +total_size_dist = group.file_size_sample_distribution + +""" +## $(pretty(group.judgement)) Group: $(join(["`$(n)`" for n in group.names], ", ")) + +$(if isempty(group.not_available) + """ + Size estimate for this group (based on a radom sample): $( + group.num_possibilities + ) files, $( + format_filesize(total_size_dist) + ) $(exceeds_limit(group.judgement)) + + | Name | Possible values | + |---|---| + $(map(zip(group.names, group.possible_values)) do (n, vs) + "| `$(n)` | **$(length(vs))** | \n" + end |> join) + """ +else + notgivens = [k for (k,v) in group.not_available if v == :NotGiven] + infinites = [k for (k,v) in group.not_available if v == :InfinitePossibilities] + remainder = setdiff(keys(group.not_available), notgivens ∪ infinites) + + """ + This group could not be precomputed because: + $( + isempty(notgivens) ? "" : "- The set of possible values for $(join(("`$(s)`" for s in notgivens), ", ")) is not known. If you are using PlutoUI, be sure to use an up-to-date version. If this input element is custom-made, take a look at `AbstractPlutoDingetjes.jl`. \n" + )$( + isempty(infinites) ? "" : "- The set of possible values for $(join(("`$(s)`" for s in infinites), ", ")) is infinite. \n" + )$( + isempty(remainder) ? "" : "- The set of possible values for $(join(("`$(s)`" for s in remainder), ", ")) could not be determined because of an unknown reason: `$( + join((group.not_available[k] for k in remainder), ", ") + )`. \n" + ) + """ +end) + +""" +end |> join) +""", + ) + show(io, m, r) +end + + +format_filesize(x::Real) = isnan(x) ? "NaN" : try + Base.format_bytes(floor(BigInt, x)) +catch + "$(x / 1e6) MB" +end + +function format_filesize(x::Distribution) + m, s = mean(x), std(x) + if s / m > 0.05 + "$(format_filesize(m)) (𝜎 $(format_filesize(s)))" + else + format_filesize(m) + end +end + + + + +# Some missing functionality + + +function Random.rand( + rng::Random.AbstractRNG, + iterator::Random.SamplerTrivial{Base.Iterators.ProductIterator{T}}, +) where {T} + r(x) = rand(rng, x) + r.(iterator[].iterators) +end + +function Random.rand( + rng::Random.AbstractRNG, + iterator::Random.SamplerTrivial{Base.Generator{T,F}}, +) where {T,F} + iterator[].f(rand(rng, iterator[].iter)) +end + +sum_distributions(ds; init=Normal(0, 0)) = + any(isnothing, ds) ? nothing : reduce(convolve, ds; init=init) + + +pretty(j::Judgement) = + if j.should_precompute_all + j.close_to_filesize_limit ? "⚠️" : "✓" + else + "❌" + end diff --git a/src/run_bonds.jl b/src/run_bonds.jl new file mode 100644 index 0000000..de5c937 --- /dev/null +++ b/src/run_bonds.jl @@ -0,0 +1,66 @@ +import Pluto: + Pluto, without_pluto_file_extension, generate_html, @asynclog, withtoken, Firebasey +using Base64 +using SHA +using FromFile + +@from "./Types.jl" import RunningNotebook + +function run_bonds_get_patches( + server_session::Pluto.ServerSession, + run::RunningNotebook, + bonds::AbstractDict{Symbol,<:Any}, +)::Union{AbstractDict{String,Any},Nothing} + + notebook = run.notebook + + topological_order, new_state = withtoken(run.token) do + try + notebook.bonds = bonds + + names::Vector{Symbol} = Symbol.(keys(bonds)) + + topological_order = Pluto.set_bond_values_reactive( + session=server_session, + notebook=notebook, + bound_sym_names=names, + is_first_values=[false for _n in names], # because requests should be stateless. We might want to do something special for the (actual) initial request (containing every initial bond value) in the future. + run_async=false, + )::Pluto.TopologicalOrder + + new_state = Pluto.notebook_to_js(notebook) + + topological_order, new_state + catch e + @error "Failed to set bond values" exception = (e, catch_backtrace()) + nothing, nothing + end + end + if topological_order === nothing + return nothing + end + + ids_of_cells_that_ran = [c.cell_id for c in topological_order.runnable] + + @debug "Finished running!" length(ids_of_cells_that_ran) + + # We only want to send state updates about... + function only_relevant(state) + new = copy(state) + # ... the cells that just ran and ... + new["cell_results"] = filter(state["cell_results"]) do (id, cell_state) + id ∈ ids_of_cells_that_ran + end + # ... nothing about bond values, because we don't want to synchronize among clients. + new["bonds"] = Dict{String,Dict{String,Any}}() + new + end + + patches = Firebasey.diff(only_relevant(run.original_state), only_relevant(new_state)) + patches_as_dicts::Array{Dict} = Firebasey._convert(Array{Dict}, patches) + + Dict{String,Any}( + "patches" => patches_as_dicts, + "ids_of_cells_that_ran" => ids_of_cells_that_ran, + ) +end diff --git a/test/basic3.jl b/test/basic3.jl new file mode 100644 index 0000000..9bca2ce --- /dev/null +++ b/test/basic3.jl @@ -0,0 +1,275 @@ +### A Pluto.jl notebook ### +# v0.19.26 + +using Markdown +using InteractiveUtils + +# This Pluto notebook uses @bind for interactivity. When running this notebook outside of Pluto, the following 'mock version' of @bind gives bound variables a default value (instead of an error). +macro bind(def, element) + quote + local iv = try Base.loaded_modules[Base.PkgId(Base.UUID("6e696c72-6542-2067-7265-42206c756150"), "AbstractPlutoDingetjes")].Bonds.initial_value catch; b -> missing; end + local el = $(esc(element)) + global $(esc(def)) = Core.applicable(Base.get, el) ? Base.get(el) : iv(el) + el + end +end + +# ╔═╡ 22dc8ce8-392e-4202-a549-f0fd46152322 +import AbstractPlutoDingetjes.Bonds + +# ╔═╡ 9b342ef4-fff9-46d0-b316-cc383fe71a59 +begin + struct CoolSlider + max + end + function Base.show(io::IO, ::MIME"text/html", s::CoolSlider) + write(io, "") + end + Bonds.initial_value(::CoolSlider) = 1 + Bonds.possible_values(s::CoolSlider) = 1:s.max +end + +# ╔═╡ 4a683caa-6910-4922-bda4-c3a00950a14b +begin + struct CoolText + end + function Base.show(io::IO, ::MIME"text/html", s::CoolText) + write(io, "") + end + Bonds.initial_value(::CoolText) = "" + Bonds.possible_values(s::CoolText) = Bonds.InfinitePossibilities() +end + +# ╔═╡ 635e5ebc-6567-11eb-1d9d-f98bfca7ec27 +@bind x CoolSlider(10) + +# ╔═╡ ad853ac9-a8a0-44ef-8d41-c8cea165ad57 +@bind y CoolSlider(20) + +# ╔═╡ 26025270-9b5e-4841-b295-0c47437bc7db +repeat(string(x), y) + +# ╔═╡ 8fb4ff71-6f86-4643-a0af-6b6665d63634 + + +# ╔═╡ ea600f99-b634-492f-a1d6-7137ff896e84 +@bind z CoolSlider(15) + +# ╔═╡ 815f3a2d-b5f6-4b5d-84ec-fa660c3dbfe8 +z * 100 + +# ╔═╡ 1352da54-e567-4f59-a3da-19ed3f4bb7c7 + + +# ╔═╡ dca76d5d-bb71-4a81-9f9e-19ddfbc258cd +@bind a1 CoolSlider(10) + +# ╔═╡ a3ab6582-3d30-4594-8eb6-17f3c4103076 +@bind a2 CoolSlider(10) + +# ╔═╡ 6ee0893c-45f3-490e-a53c-7b3d92bc8f70 +f = () -> a1 + a2 + +# ╔═╡ 7ad5c51b-76e4-44d3-aeca-f81a8f22f8b4 +f() + +# ╔═╡ 1681132d-050d-4ef7-9caa-14be6eade03d + + +# ╔═╡ 6ca3eca5-2a0b-4c9c-84c7-307fd4a29eae + + +# ╔═╡ 09ae27fa-525a-4211-b252-960cdbaf1c1e +b = @bind s html"" + +# ╔═╡ ed78a6f1-d282-4d80-8f42-40701aeadb52 +b + +# ╔═╡ cca2f726-0c25-43c6-85e4-c16ec192d464 +s + +# ╔═╡ c4f51980-3c30-4d3f-a76a-fc0f0fe16944 + + +# ╔═╡ 8f0bd329-36b8-45ed-b80d-24661242129a +b2 = @bind s2 CoolText() + +# ╔═╡ c55a107f-5d7d-4396-b597-8c1ae07c35be +b2 + +# ╔═╡ a524ff27-a6a3-4f14-8ed4-f55700647bc4 +sleep(1); s2 + +# ╔═╡ 00000000-0000-0000-0000-000000000001 +PLUTO_PROJECT_TOML_CONTENTS = """ +[deps] +AbstractPlutoDingetjes = "6e696c72-6542-2067-7265-42206c756150" + +[compat] +AbstractPlutoDingetjes = "~1.1.2" +""" + +# ╔═╡ 00000000-0000-0000-0000-000000000002 +PLUTO_MANIFEST_TOML_CONTENTS = """ +# This file is machine-generated - editing it directly is not advised + +[[AbstractPlutoDingetjes]] +deps = ["Pkg"] +git-tree-sha1 = "abb72771fd8895a7ebd83d5632dc4b989b022b5b" +uuid = "6e696c72-6542-2067-7265-42206c756150" +version = "1.1.2" + +[[ArgTools]] +uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.1" + +[[Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" + +[[Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" + +[[Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" + +[[Downloads]] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] +uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.6.0" + +[[FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" + +[[InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" + +[[LibCURL]] +deps = ["LibCURL_jll", "MozillaCACerts_jll"] +uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.3" + +[[LibCURL_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] +uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "7.84.0+0" + +[[LibGit2]] +deps = ["Base64", "NetworkOptions", "Printf", "SHA"] +uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" + +[[LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "MbedTLS_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.10.2+0" + +[[Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" + +[[Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" + +[[Markdown]] +deps = ["Base64"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" + +[[MbedTLS_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.2+0" + +[[MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2022.10.11" + +[[NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.2.0" + +[[Pkg]] +deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] +uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +version = "1.9.0" + +[[Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" + +[[REPL]] +deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] +uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" + +[[Random]] +deps = ["SHA", "Serialization"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" + +[[SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" + +[[Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" + +[[TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[Tar]] +deps = ["ArgTools", "SHA"] +uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" +version = "1.10.0" + +[[UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[[Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" + +[[Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.2.13+0" + +[[nghttp2_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.48.0+0" + +[[p7zip_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" +version = "17.4.0+0" +""" + +# ╔═╡ Cell order: +# ╠═22dc8ce8-392e-4202-a549-f0fd46152322 +# ╟─9b342ef4-fff9-46d0-b316-cc383fe71a59 +# ╟─4a683caa-6910-4922-bda4-c3a00950a14b +# ╠═635e5ebc-6567-11eb-1d9d-f98bfca7ec27 +# ╠═ad853ac9-a8a0-44ef-8d41-c8cea165ad57 +# ╠═26025270-9b5e-4841-b295-0c47437bc7db +# ╟─8fb4ff71-6f86-4643-a0af-6b6665d63634 +# ╠═ea600f99-b634-492f-a1d6-7137ff896e84 +# ╠═815f3a2d-b5f6-4b5d-84ec-fa660c3dbfe8 +# ╟─1352da54-e567-4f59-a3da-19ed3f4bb7c7 +# ╠═dca76d5d-bb71-4a81-9f9e-19ddfbc258cd +# ╠═a3ab6582-3d30-4594-8eb6-17f3c4103076 +# ╠═6ee0893c-45f3-490e-a53c-7b3d92bc8f70 +# ╠═7ad5c51b-76e4-44d3-aeca-f81a8f22f8b4 +# ╠═1681132d-050d-4ef7-9caa-14be6eade03d +# ╟─6ca3eca5-2a0b-4c9c-84c7-307fd4a29eae +# ╠═09ae27fa-525a-4211-b252-960cdbaf1c1e +# ╠═ed78a6f1-d282-4d80-8f42-40701aeadb52 +# ╠═cca2f726-0c25-43c6-85e4-c16ec192d464 +# ╟─c4f51980-3c30-4d3f-a76a-fc0f0fe16944 +# ╠═8f0bd329-36b8-45ed-b80d-24661242129a +# ╠═c55a107f-5d7d-4396-b597-8c1ae07c35be +# ╠═a524ff27-a6a3-4f14-8ed4-f55700647bc4 +# ╟─00000000-0000-0000-0000-000000000001 +# ╟─00000000-0000-0000-0000-000000000002 diff --git a/test/runtests.jl b/test/runtests.jl index 86054d4..99162dd 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -17,6 +17,7 @@ else cache_dir = tempname(cleanup=false) ENV["HIDE_PLUTO_EXACT_VERSION_WARNING"] = "true" + include("./staterequest static.jl") include("./plutohash.jl") include("./configuration.jl") include("./static export.jl") diff --git a/test/staterequest static.jl b/test/staterequest static.jl new file mode 100644 index 0000000..4ca42ec --- /dev/null +++ b/test/staterequest static.jl @@ -0,0 +1,163 @@ +import PlutoSliderServer: PlutoSliderServer, Pluto, list_files_recursive +import HTTP +import Deno_jll +using OrderedCollections + +import Random +using Test +using UUIDs +using Base64 + +@testset "HTTP requests" begin + test_dir = tempname(cleanup=false) + cp(@__DIR__, test_dir) + + notebook_paths = ["basic3.jl"] + # notebook_paths = ["basic2.jl", "parallelpaths4.jl"] + + port = rand(Random.RandomDevice(), 12345:65000) + + + still_booting = Ref(true) + ready_result = Ref{Any}(nothing) + function on_ready(result) + ready_result[] = result + still_booting[] = false + end + + + t = Pluto.@asynclog begin + try + run( + `$(Deno_jll.deno()) run --allow-net --allow-read https://deno.land/std@0.115.0/http/file_server.ts $(test_dir) --cors --port $(port)`, + ) + catch e + if !(e isa TaskFailedException) && !(e isa InterruptException) + showerror(stderr, e, stacktrace(catch_backtrace())) + end + end + end + + withenv("JULIA_DEBUG" => nothing) do + PlutoSliderServer.export_directory( + test_dir; + Precompute_enabled=true, + notebook_paths, + on_ready, + ) + end + + @test isdir(joinpath(test_dir, "staterequest")) + @test isdir(joinpath(test_dir, "bondconnections")) + @show list_files_recursive(joinpath(test_dir, "staterequest")) + @show readdir(joinpath(test_dir, "bondconnections")) + @test length(list_files_recursive(joinpath(test_dir, "staterequest"))) == let + x = 10 + y = 20 + z = 15 + a1, a2 = 10, 10 + x * y + z + a1 * a2 + end + + while !occursin( + "Pluto.jl notebook", + read(download("http://localhost:$(port)/basic3.jl"), String), + ) + @info "Waiting for file server to start" + sleep(0.1) + end + + while still_booting[] + sleep(0.1) + end + + + notebook_sessions = ready_result[].notebook_sessions + + @show notebook_paths [ + (s.path, typeof(s.run), s.current_hash) for s in notebook_sessions + ] + + @testset "Bond connections - $(name)" for (i, name) in enumerate(notebook_paths) + s = notebook_sessions[i] + + for ending in [""] + response = HTTP.get( + "http://localhost:$(port)/bondconnections/$(HTTP.URIs.escapeuri(s.current_hash))" * + ending, + ) + + result = Pluto.unpack(response.body) + + @test result == + Dict(String(k) => String.(v) for (k, v) in s.run.bond_connections) + end + end + + + @testset "State request - basic3.jl" begin + i = 1 + s = notebook_sessions[i] + + @testset "Method $(method)" for method in ["GET"], x = 3:7 + + v(x) = OrderedDict("value" => x) + + bonds = OrderedDict("x" => v(x), "y" => v(7)) + + state = Pluto.unpack(Pluto.pack(s.run.original_state)) + + sum_cell_id = "26025270-9b5e-4841-b295-0c47437bc7db" + + response = if method == "GET" + arg = Pluto.pack(bonds) |> PlutoSliderServer.base64urlencode + + HTTP.request( + method, + "http://localhost:$(port)/staterequest/$(s.current_hash)/$(arg)", + ) + else + HTTP.request( + method, + "http://localhost:$(port)/staterequest/$(s.current_hash)/", + [], + Pluto.pack(bonds), + ) + end + + result = Pluto.unpack(response.body) + + @test sum_cell_id ∈ result["ids_of_cells_that_ran"] + + for patch in result["patches"] + Pluto.Firebasey.applypatch!( + state, + convert(Pluto.Firebasey.JSONPatch, patch), + ) + end + + @test state["cell_results"][sum_cell_id]["output"]["body"] == + let + x = bonds["x"]["value"] + y = bonds["y"]["value"] + repeat(string(x), y) + end |> repr + + end + end + + # close(ready_result[].serversocket) + + try + schedule(t, InterruptException(); error=true) + wait(t) + catch e + if !(e isa TaskFailedException) && !(e isa InterruptException) + rethrow(e) + end + end + # schedule(t, InterruptException(), error=true) + @info "DONEZO" + + @test true +end \ No newline at end of file