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
48 changes: 48 additions & 0 deletions bench/bundle_scaling.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Run from the repository root: mix run bench/bundle_scaling.exs
# Measures bundling only; schema construction and warmup are outside timing.
IO.puts("Elixir #{System.version()}, OTP #{System.otp_release()}, schedulers #{System.schedulers_online()}")

measure = fn label, bundle ->
Enum.each(1..5, fn _ -> bundle.() end)
samples = for _ <- 1..21 do
:erlang.garbage_collect()
{microseconds, _} = :timer.tc(fn -> Enum.each(1..10, fn _ -> bundle.() end) end)
microseconds / 10 / 1000
end
median = samples |> Enum.sort() |> Enum.at(10)
IO.puts("#{label} median_ms=#{Float.round(median, 4)} (21 batches of 10)")
end

for depth <- [100, 200, 400] do
schema = Enum.reduce(1..depth, %{"type" => "integer"}, fn _, child ->
%{"properties" => %{"child" => child}}
end)

bundle = fn ->
{:ok, _} = JSONSchex.bundle_fragment(schema, entry: "#")
end

measure.("depth=#{depth}", bundle)
end

# Repeated names are intentionally outside schema keywords, exercising fallback discovery.
for count <- [500, 1000, 2000] do
document = %{"schema" => %{}, "x-candidates" => List.duplicate(%{"$anchor" => "same"}, count)}
measure.("anchors=#{count}", fn ->
{:ok, _} = JSONSchex.bundle_fragment(document, entry: "#/schema")
end)
end

# Identical payloads and loaders except for the effective resource base.
# Unselected deep refs must still be rewritten in the redirected case.
for count <- [500, 1000, 2000], mode <- [:identity, :redirected] do
payload = List.duplicate(%{"nested" => [%{"$ref" => "/api/value.json#value"}]}, count)
document = %{"$ref" => "/api/value.json", "x-payload" => payload}
base = if mode == :identity, do: "/api/value.json", else: "/mirror/value.json"
loader = fn "/api/value.json" ->
{:ok, %{document: %{"type" => "integer", "x-payload" => payload}, base_uri: base}}
end
measure.("aliases=#{mode} payload=#{count}", fn ->
{:ok, _} = JSONSchex.bundle_fragment(document, entry: "#", base_uri: "/api/root.json", loader: loader)
end)
end
70 changes: 41 additions & 29 deletions lib/jsonschex/compiler/fragment/bundle.ex
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,15 @@ defmodule JSONSchex.Compiler.Fragment.Bundle do
bundle <- put_context_document(bundle, document, entry_resource, base_uri),
{:ok, {external_resources, reachable_anchors, base_aliases}} <-
collect_external_resources(document, entry_schema, entry_base, entry_resources, base_uri, opts) do
bundle = rewrite_external_refs(bundle, entry_base, base_aliases)
# Resolution retains every alias; rewriting only needs actual redirects.
# An empty map lets identity-only bundles skip all deep rewrite passes.
rewrite_aliases = Map.reject(base_aliases, fn {base, target} -> base == target end)
bundle = rewrite_external_refs(bundle, entry_base, rewrite_aliases)

bundle =
bundle
|> put_external_resources(external_resources, base_aliases)
|> put_reachable_anchors(reachable_anchors, base_aliases)
|> put_external_resources(external_resources, rewrite_aliases)
|> put_reachable_anchors(reachable_anchors, rewrite_aliases)

{:ok, bundle}
end
Expand Down Expand Up @@ -177,10 +180,6 @@ defmodule JSONSchex.Compiler.Fragment.Bundle do

defp index_schema_tree(state, _schema, _current_base), do: state





# Anchors have no URI that can be loaded, so a containing non-schema document
# needs a discovery fallback. These candidates never register `$id` resources
# or dynamic scope and are consulted only after authoritative schema metadata.
Expand Down Expand Up @@ -216,15 +215,13 @@ defmodule JSONSchex.Compiler.Fragment.Bundle do
defp put_fallback_anchor(state, base_uri, anchor, schema, path) do
anchor_uri = URIUtil.resolve(base_uri, "#" <> anchor)
candidate_path = Enum.reverse(path)
candidate = {schema, base_uri, candidate_path}
candidate = {schema, base_uri}

# A location with both anchor keywords is one candidate; equal schemas at
# different locations remain distinct. Keys hash paths, never schema trees.
candidates =
Map.update(state.fallback_anchors, anchor_uri, [candidate], fn existing ->
if Enum.any?(existing, fn {_schema, _base, path} -> path == candidate_path end) do
existing
else
existing ++ [candidate]
end
Map.update(state.fallback_anchors, anchor_uri, %{candidate_path => candidate}, fn existing ->
Map.put_new(existing, candidate_path, candidate)
end)

%{state | fallback_anchors: candidates}
Expand Down Expand Up @@ -254,33 +251,42 @@ defmodule JSONSchex.Compiler.Fragment.Bundle do
base
end

# Entry and reference targets may come from arbitrary document locations, so
# index their metadata before walking. Structural children are already covered
# by this pass, including their inactive definitions and nested resource ids.
defp walk_reachable(schema, current_base, dynamic_scope, state) when is_map(schema) do
state = index_schema_tree(state, schema, current_base)
walk_indexed_reachable(schema, current_base, dynamic_scope, state)
end

defp walk_reachable(list, current_base, dynamic_scope, state) when is_list(list) do
Enum.reduce_while(list, {:ok, state}, fn value, {:ok, acc} ->
case walk_reachable(value, current_base, dynamic_scope, acc) do
{:ok, next} -> {:cont, {:ok, next}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end

defp walk_reachable(_value, _current_base, _dynamic_scope, state), do: {:ok, state}

defp walk_indexed_reachable(schema, current_base, dynamic_scope, state) when is_map(schema) do
new_base = resolve_base_from_id(schema, current_base)
dynamic_scope = put_dynamic_scope(dynamic_scope, resource_key(new_base))

with {:ok, state} <- follow_schema_refs(schema, new_base, dynamic_scope, state) do
schema
|> SchemaTraversal.active_subschemas()
|> Enum.reduce_while({:ok, state}, fn subschema, {:ok, acc} ->
case walk_reachable(subschema, new_base, dynamic_scope, acc) do
case walk_indexed_reachable(subschema, new_base, dynamic_scope, acc) do
{:ok, next} -> {:cont, {:ok, next}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end
end

defp walk_reachable(list, current_base, dynamic_scope, state) when is_list(list) do
Enum.reduce_while(list, {:ok, state}, fn value, {:ok, acc} ->
case walk_reachable(value, current_base, dynamic_scope, acc) do
{:ok, next} -> {:cont, {:ok, next}}
{:error, error} -> {:halt, {:error, error}}
end
end)
end

defp walk_reachable(_value, _current_base, _dynamic_scope, state), do: {:ok, state}
defp walk_indexed_reachable(_value, _current_base, _dynamic_scope, state), do: {:ok, state}

defp put_dynamic_scope({stack, members} = dynamic_scope, base) do
if Map.has_key?(members, base) do
Expand Down Expand Up @@ -451,10 +457,16 @@ defmodule JSONSchex.Compiler.Fragment.Bundle do
end

defp find_fallback_anchor(anchor_uri, state) do
case Map.get(state.fallback_anchors, anchor_uri, []) do
[{target, inherited_base, _path}] -> {:ok, target, inherited_base}
[] -> nil
candidates -> {:ambiguous_anchor, anchor_uri, length(candidates)}
case Map.get(state.fallback_anchors, anchor_uri, %{}) do
candidates when map_size(candidates) == 0 ->
nil

candidates when map_size(candidates) == 1 ->
[{target, inherited_base}] = Map.values(candidates)
{:ok, target, inherited_base}

candidates ->
{:ambiguous_anchor, anchor_uri, map_size(candidates)}
end
end

Expand Down
Loading