ruact 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Story
|
|
3
|
+
# Story 9.9 — NFR21 dispatch-overhead benchmark, re-pointed at REAL routes.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# The v1 substrate (the synthetic `POST /__ruact/fn/:name` endpoint + the
|
|
6
|
+
# `ruact_action` DSL) was demolished, so this bench now exercises the
|
|
7
|
+
# route-driven (v2) contract:
|
|
7
8
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# - A non-GET REST route (`POST /posts`) on a controller that does
|
|
10
|
+
# `include Ruact::Server` — Bucket-2 JSON dispatch (the generated accessor
|
|
11
|
+
# sends `Accept: application/json`), compared against a plain controller
|
|
12
|
+
# doing the SAME `Post.create!`. The only delta is the concern's callback
|
|
13
|
+
# chain (the structured-error `rescue_from` + the upload guard).
|
|
14
|
+
# - The multipart (`<form action>`) shape on the same route.
|
|
15
|
+
# - A query route (`GET /q/<jsId>`) drawn by `ruact_queries`.
|
|
12
16
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# the
|
|
16
|
-
#
|
|
17
|
+
# NFR21 budget: server-function dispatch adds < 20ms over a standard Rails
|
|
18
|
+
# request. NOTE (Story 9.9 D2): the v2 path is a REAL Rails route through the
|
|
19
|
+
# full router + the `Ruact::Server` concern — a different code path than the
|
|
20
|
+
# deleted v1 synthetic endpoint, so there is no apples-to-apples per-call delta
|
|
21
|
+
# against the historical v1 baseline. The honest gate is "p50/p95 < 20ms holds."
|
|
17
22
|
#
|
|
18
|
-
#
|
|
23
|
+
# v1 baseline (historical, Story 9.1, 2026-06-05): ruact_action JSON dispatch
|
|
24
|
+
# p50 0.973ms / p95 1.344ms; plain p50 0.771ms / p95 1.058ms; overhead +0.202ms
|
|
25
|
+
# p50 / +0.286ms p95; multipart p50 1.05ms / p95 1.494ms — all PASS < 20ms.
|
|
19
26
|
#
|
|
27
|
+
# Run with:
|
|
20
28
|
# bundle exec ruby bench/server_functions_dispatch_bench.rb
|
|
21
29
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# CI/nightly:
|
|
26
|
-
# `.github/workflows/server-functions-bench.yml` runs this script on
|
|
27
|
-
# `schedule: cron: "0 6 * * *"` (nightly) and on any PR touching
|
|
28
|
-
# `lib/ruact/server_functions/**`. The workflow does NOT gate merge —
|
|
29
|
-
# it posts a comment with the numbers so accidental 10× regressions
|
|
30
|
-
# surface in PR feedback (per AC12: "at minimum, a non-blocking
|
|
31
|
-
# nightly job").
|
|
30
|
+
# CI/nightly: `.github/workflows/server-functions-bench.yml` runs this on a
|
|
31
|
+
# nightly cron + any PR touching `lib/ruact/server_functions/**`. Non-blocking.
|
|
32
32
|
|
|
33
33
|
require "bundler/setup"
|
|
34
34
|
require "benchmark/ips"
|
|
@@ -40,8 +40,9 @@ require "rack/test"
|
|
|
40
40
|
|
|
41
41
|
require "ruact"
|
|
42
42
|
require "ruact/controller"
|
|
43
|
-
require "ruact/
|
|
44
|
-
require "ruact/
|
|
43
|
+
require "ruact/server"
|
|
44
|
+
require "ruact/routing"
|
|
45
|
+
require "ruact/query"
|
|
45
46
|
|
|
46
47
|
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
47
48
|
ActiveRecord::Schema.verbose = false
|
|
@@ -57,6 +58,10 @@ class Post < ActiveRecord::Base
|
|
|
57
58
|
validates :title, presence: true
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
# No host ApplicationController in this minimal bench — point query dispatch at
|
|
62
|
+
# ActionController::Base so `ruact_queries` resolves its parent controller.
|
|
63
|
+
Ruact.configure { |c| c.query_parent_controller = "ActionController::Base" }
|
|
64
|
+
|
|
60
65
|
class BenchApp < Rails::Application
|
|
61
66
|
config.eager_load = false
|
|
62
67
|
config.consider_all_requests_local = false
|
|
@@ -68,84 +73,101 @@ class BenchApp < Rails::Application
|
|
|
68
73
|
config.hosts.clear if config.respond_to?(:hosts)
|
|
69
74
|
end
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
# v2 mutation host — a normal controller that includes Ruact::Server. The
|
|
77
|
+
# `create` action is a non-GET REST route; the generated accessor calls it with
|
|
78
|
+
# `Accept: application/json`, so the concern serves Bucket-2 JSON.
|
|
79
|
+
class PostsController < ActionController::Base
|
|
80
|
+
include Ruact::Server
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
def create
|
|
83
|
+
payload = JSON.parse(request.raw_post)
|
|
84
|
+
@post = Post.create!(title: payload["title"], body: payload["body"])
|
|
85
|
+
render(json: { id: @post.id, title: @post.title })
|
|
77
86
|
end
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
# Multipart (`<form action={fn}>`) dispatch on the SAME Ruact::Server host:
|
|
89
|
+
# the request still flows through the concern (upload guard + error gate +
|
|
90
|
+
# Accept negotiation) — reads Rack-parsed multipart params.
|
|
91
|
+
def create_multipart
|
|
92
|
+
@post = Post.create!(title: params[:title], body: params[:body])
|
|
93
|
+
render(json: { id: @post.id, title: @post.title })
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Plain baseline — same Post.create! work, no concern. `create_multipart` reads
|
|
98
|
+
# Rack-parsed multipart params (the `<form action>` wire shape).
|
|
99
|
+
class PlainController < ActionController::Base
|
|
100
|
+
def create
|
|
80
101
|
payload = JSON.parse(request.raw_post)
|
|
81
102
|
post = Post.create!(title: payload["title"], body: payload["body"])
|
|
82
103
|
render(json: { id: post.id, title: post.title })
|
|
83
104
|
end
|
|
84
|
-
end
|
|
85
105
|
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
def create_multipart
|
|
107
|
+
post = Post.create!(title: params[:title], body: params[:body])
|
|
108
|
+
render(json: { id: post.id, title: post.title })
|
|
109
|
+
end
|
|
88
110
|
end
|
|
89
111
|
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
extend Ruact::ServerAction
|
|
95
|
-
|
|
96
|
-
ruact_action :bench_action do |params|
|
|
97
|
-
post = Post.create!(title: params[:title], body: params[:body])
|
|
98
|
-
{ id: post.id, title: post.title }
|
|
112
|
+
# v2 read host — a Ruact::Query mounted via ruact_queries → GET /q/<jsId>.
|
|
113
|
+
class CatalogQuery < Ruact::Query
|
|
114
|
+
def recent
|
|
115
|
+
Post.order(created_at: :desc).limit(5).pluck(:title)
|
|
99
116
|
end
|
|
100
117
|
end
|
|
101
118
|
|
|
119
|
+
BenchApp.routes.append do
|
|
120
|
+
post "/posts", to: "posts#create"
|
|
121
|
+
post "/posts_mp", to: "posts#create_multipart"
|
|
122
|
+
post "/plain", to: "plain#create"
|
|
123
|
+
post "/plain_mp", to: "plain#create_multipart"
|
|
124
|
+
ruact_queries CatalogQuery
|
|
125
|
+
end
|
|
126
|
+
|
|
102
127
|
BenchApp.instance.initialize!
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
|
|
128
|
+
# API mode so CSRF doesn't reject the bench requests (no session middleware in
|
|
129
|
+
# this minimal Rack::Test setup).
|
|
130
|
+
PostsController.allow_forgery_protection = false
|
|
106
131
|
|
|
107
132
|
include Rack::Test::Methods
|
|
108
133
|
|
|
109
134
|
def app = BenchApp.instance
|
|
110
135
|
|
|
111
|
-
# Counter ensures titles stay unique under the AR validation; without
|
|
112
|
-
# this, repeated calls would all attempt to insert the same row and
|
|
113
|
-
# the bench would measure the validation-failure path, not the
|
|
114
|
-
# create-success path.
|
|
115
136
|
counter = 0
|
|
116
137
|
body_for = lambda do
|
|
117
138
|
counter += 1
|
|
118
139
|
{ title: "Post #{counter}", body: "body" }.to_json
|
|
119
140
|
end
|
|
120
|
-
|
|
141
|
+
json_headers = { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json" }
|
|
121
142
|
|
|
122
|
-
post("/
|
|
143
|
+
post("/posts", body_for.call, json_headers)
|
|
123
144
|
unless last_response.status == 200
|
|
124
|
-
raise "ruact dispatch broken (status=#{last_response.status} body=#{last_response.body})"
|
|
145
|
+
raise "ruact server dispatch broken (status=#{last_response.status} body=#{last_response.body})"
|
|
125
146
|
end
|
|
126
147
|
|
|
127
|
-
post("/plain", body_for.call,
|
|
148
|
+
post("/plain", body_for.call, json_headers)
|
|
128
149
|
raise "plain dispatch broken (status=#{last_response.status})" unless last_response.status == 200
|
|
129
150
|
|
|
151
|
+
get("/q/recent", {}, { "HTTP_ACCEPT" => "application/json" })
|
|
152
|
+
raise "query dispatch broken (status=#{last_response.status})" unless last_response.status == 200
|
|
153
|
+
|
|
130
154
|
Benchmark.ips do |x|
|
|
131
155
|
x.config(time: 3, warmup: 1)
|
|
132
156
|
|
|
133
|
-
x.report("
|
|
134
|
-
post("/
|
|
157
|
+
x.report("Ruact::Server dispatch (POST /posts, Post.create!)") do
|
|
158
|
+
post("/posts", body_for.call, json_headers)
|
|
135
159
|
end
|
|
136
160
|
|
|
137
161
|
x.report("plain controller action (Post.create!)") do
|
|
138
|
-
post("/plain", body_for.call,
|
|
162
|
+
post("/plain", body_for.call, json_headers)
|
|
139
163
|
end
|
|
140
164
|
|
|
141
165
|
x.compare!
|
|
142
166
|
end
|
|
143
167
|
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
# slow outliers (GC pause, OS scheduler) would otherwise inflate the
|
|
148
|
-
# mean and produce misleading regression alerts.
|
|
168
|
+
# NFR21 asks for the MEDIAN per-call number. Sample N individual requests so we
|
|
169
|
+
# can compute the median + the percentile spread; the median is load-bearing (a
|
|
170
|
+
# few GC/scheduler outliers would inflate the mean).
|
|
149
171
|
def time_one_seconds
|
|
150
172
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
151
173
|
yield
|
|
@@ -162,8 +184,8 @@ def percentile(sorted, pct)
|
|
|
162
184
|
end
|
|
163
185
|
|
|
164
186
|
SAMPLES = 1000
|
|
165
|
-
ruact_samples = sample_times(SAMPLES) { post("/
|
|
166
|
-
plain_samples = sample_times(SAMPLES) { post("/plain", body_for.call,
|
|
187
|
+
ruact_samples = sample_times(SAMPLES) { post("/posts", body_for.call, json_headers) }
|
|
188
|
+
plain_samples = sample_times(SAMPLES) { post("/plain", body_for.call, json_headers) }
|
|
167
189
|
|
|
168
190
|
ruact_p50 = percentile(ruact_samples, 50) * 1000
|
|
169
191
|
plain_p50 = percentile(plain_samples, 50) * 1000
|
|
@@ -177,20 +199,17 @@ overhead_p95 = (ruact_p95 - plain_p95).round(3)
|
|
|
177
199
|
|
|
178
200
|
puts ""
|
|
179
201
|
puts "#{SAMPLES} requests (Post.create! body):"
|
|
180
|
-
puts "
|
|
202
|
+
puts " Ruact::Server dispatch: total=#{(ruact_total * 1000).round(1)}ms p50=#{ruact_p50.round(3)}ms p95=#{ruact_p95.round(3)}ms"
|
|
181
203
|
puts " plain controller action: total=#{(plain_total * 1000).round(1)}ms p50=#{plain_p50.round(3)}ms p95=#{plain_p95.round(3)}ms"
|
|
182
204
|
puts " per-call overhead (p50): +#{overhead_p50}ms"
|
|
183
205
|
puts " per-call overhead (p95): +#{overhead_p95}ms"
|
|
184
206
|
puts ""
|
|
185
|
-
puts "
|
|
207
|
+
puts "NFR21 target: MEDIAN Ruact::Server dispatch p50/p95 < 20 ms per call " \
|
|
208
|
+
"(p50 #{ruact_p50.round(3)}ms #{ruact_p50 < 20 ? 'PASS' : 'FAIL'}, " \
|
|
209
|
+
"p95 #{ruact_p95.round(3)}ms #{ruact_p95 < 20 ? 'PASS' : 'FAIL'})"
|
|
186
210
|
|
|
187
211
|
# ----------------------------------------------------------------------------
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
# AC11: median multipart per-call overhead stays within 1.2× of the JSON
|
|
191
|
-
# baseline above. Multipart parsing is heavier than JSON parsing — Rails'
|
|
192
|
-
# multipart parser allocates a temp file per part and walks the boundary
|
|
193
|
-
# stream — but for sub-1KB bodies the cost should be negligible.
|
|
212
|
+
# Multipart (`<form action={fn}>`) dispatch overhead on the same route.
|
|
194
213
|
# ----------------------------------------------------------------------------
|
|
195
214
|
|
|
196
215
|
require "securerandom"
|
|
@@ -216,10 +235,9 @@ multipart_counter = 0
|
|
|
216
235
|
multipart_post = lambda do
|
|
217
236
|
multipart_counter += 1
|
|
218
237
|
data, boundary = multipart_body("MP Post #{multipart_counter}", "multipart body")
|
|
219
|
-
post("/
|
|
238
|
+
post("/posts_mp", data, multipart_headers.call(boundary))
|
|
220
239
|
end
|
|
221
240
|
|
|
222
|
-
# Warm-up to ensure multipart parser is loaded
|
|
223
241
|
multipart_post.call
|
|
224
242
|
raise "multipart broken (status=#{last_response.status})" unless last_response.status == 200
|
|
225
243
|
|
|
@@ -227,83 +245,32 @@ multipart_samples = sample_times(SAMPLES) { multipart_post.call }
|
|
|
227
245
|
mp_p50 = percentile(multipart_samples, 50) * 1000
|
|
228
246
|
mp_p95 = percentile(multipart_samples, 95) * 1000
|
|
229
247
|
mp_total_ms = (multipart_samples.sum * 1000).round(1)
|
|
230
|
-
overhead_mp_vs_json_p50 = (mp_p50 - ruact_p50).round(3)
|
|
231
|
-
mp_factor = ruact_p50.zero? ? 0.0 : (mp_p50 / ruact_p50).round(3)
|
|
232
248
|
|
|
233
249
|
puts ""
|
|
234
|
-
puts "#{SAMPLES} multipart requests (
|
|
235
|
-
puts " multipart dispatch:
|
|
236
|
-
puts " vs. JSON ruact baseline: +#{overhead_mp_vs_json_p50}ms (factor=#{mp_factor}×)"
|
|
250
|
+
puts "#{SAMPLES} multipart requests (`<form action>` shape) — Ruact::Server route /posts_mp:"
|
|
251
|
+
puts " Ruact::Server multipart dispatch: total=#{mp_total_ms}ms p50=#{mp_p50.round(3)}ms p95=#{mp_p95.round(3)}ms"
|
|
237
252
|
puts ""
|
|
238
|
-
puts "
|
|
253
|
+
puts "NFR21 target: multipart p50/p95 < 20 ms per call " \
|
|
254
|
+
"(p50 #{mp_p50.round(3)}ms #{mp_p50 < 20 ? 'PASS' : 'FAIL'}, " \
|
|
255
|
+
"p95 #{mp_p95.round(3)}ms #{mp_p95 < 20 ? 'PASS' : 'FAIL'})"
|
|
239
256
|
|
|
240
257
|
# ----------------------------------------------------------------------------
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
# The standalone path SKIPS Rails' `process_action` callback chain + the
|
|
244
|
-
# host controller allocation, so it can be slightly faster than the
|
|
245
|
-
# controller-hosted JSON baseline OR slightly slower if the StandaloneContext
|
|
246
|
-
# setup adds overhead. The AC10 band catches accidental 10× regressions
|
|
247
|
-
# while accepting normal noise: 0.95× ≤ standalone_p50 / json_p50 ≤ 1.05×.
|
|
258
|
+
# Query dispatch overhead — GET /q/<jsId> (a ruact_queries-mounted read).
|
|
248
259
|
# ----------------------------------------------------------------------------
|
|
249
260
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
{ title: "Standalone Post #{standalone_counter}", body: "standalone body" }.to_json
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
post("/__ruact/fn/bench_action", standalone_body_for.call, headers)
|
|
257
|
-
unless last_response.status == 200
|
|
258
|
-
raise "standalone dispatch broken (status=#{last_response.status} body=#{last_response.body})"
|
|
259
|
-
end
|
|
261
|
+
query_get = -> { get("/q/recent", {}, { "HTTP_ACCEPT" => "application/json" }) }
|
|
262
|
+
query_get.call
|
|
263
|
+
raise "query broken (status=#{last_response.status})" unless last_response.status == 200
|
|
260
264
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
overhead_standalone_vs_json_p50 = (standalone_p50 - ruact_p50).round(3)
|
|
266
|
-
standalone_factor = ruact_p50.zero? ? 0.0 : (standalone_p50 / ruact_p50).round(3)
|
|
265
|
+
query_samples = sample_times(SAMPLES) { query_get.call }
|
|
266
|
+
q_p50 = percentile(query_samples, 50) * 1000
|
|
267
|
+
q_p95 = percentile(query_samples, 95) * 1000
|
|
268
|
+
q_total_ms = (query_samples.sum * 1000).round(1)
|
|
267
269
|
|
|
268
270
|
puts ""
|
|
269
|
-
puts "#{SAMPLES}
|
|
270
|
-
puts "
|
|
271
|
-
puts " vs. JSON controller baseline: #{'+' if overhead_standalone_vs_json_p50 >= 0}#{overhead_standalone_vs_json_p50}ms (factor=#{standalone_factor}×)"
|
|
272
|
-
puts ""
|
|
273
|
-
puts "AC10 target: standalone median within 0.95×..1.05× of JSON baseline " \
|
|
274
|
-
"(#{(0.95..1.05).cover?(standalone_factor) ? 'PASS' : 'WARN — outside band; see results.md (laptop noise dominates; 10× is the regression alert)'})"
|
|
275
|
-
|
|
276
|
-
# ----------------------------------------------------------------------------
|
|
277
|
-
# Story 8.4 — error-path overhead.
|
|
278
|
-
#
|
|
279
|
-
# The new endpoint-level `rescue_from StandardError` chain only fires on the
|
|
280
|
-
# unhappy path, so it does NOT touch the happy-path numbers above. This
|
|
281
|
-
# section measures the rescue-from cost itself: an action that always raises
|
|
282
|
-
# `RuntimeError("forced")` end-to-end through the new handler. Captures p50
|
|
283
|
-
# and p95 of the error path so future regressions (e.g., adding an expensive
|
|
284
|
-
# serializer step inside `ErrorPayload.build`) surface in nightly numbers.
|
|
285
|
-
#
|
|
286
|
-
# No regression assertion against the happy-path scenarios above — they're
|
|
287
|
-
# untouched because the new chain only fires on raise.
|
|
288
|
-
# ----------------------------------------------------------------------------
|
|
289
|
-
|
|
290
|
-
class BenchController
|
|
291
|
-
ruact_action(:forced_failure) { |_params| raise "forced bench failure" }
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# Warm-up — exercise the rescue_from chain once so the path is loaded.
|
|
295
|
-
post("/__ruact/fn/forced_failure", "{}", headers)
|
|
296
|
-
unless last_response.status == 500
|
|
297
|
-
raise "error-path warm-up did not return 500 (got status=#{last_response.status})"
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
forced_samples = sample_times(SAMPLES) { post("/__ruact/fn/forced_failure", "{}", headers) }
|
|
301
|
-
forced_p50 = percentile(forced_samples, 50) * 1000
|
|
302
|
-
forced_p95 = percentile(forced_samples, 95) * 1000
|
|
303
|
-
forced_total_ms = (forced_samples.sum * 1000).round(1)
|
|
304
|
-
forced_overhead_p50 = (forced_p50 - ruact_p50).round(3)
|
|
305
|
-
|
|
271
|
+
puts "#{SAMPLES} query requests (GET /q/recent):"
|
|
272
|
+
puts " query dispatch: total=#{q_total_ms}ms p50=#{q_p50.round(3)}ms p95=#{q_p95.round(3)}ms"
|
|
306
273
|
puts ""
|
|
307
|
-
puts "
|
|
308
|
-
|
|
309
|
-
|
|
274
|
+
puts "NFR21 target: query p50/p95 < 20 ms per call " \
|
|
275
|
+
"(p50 #{q_p50.round(3)}ms #{q_p50 < 20 ? 'PASS' : 'FAIL'}, " \
|
|
276
|
+
"p95 #{q_p95.round(3)}ms #{q_p95 < 20 ? 'PASS' : 'FAIL'})"
|
|
@@ -119,3 +119,32 @@ scenarios above are the load-bearing gates.
|
|
|
119
119
|
If a future change pushes the error path above ~5 ms p50 without a clear
|
|
120
120
|
reason (e.g., a backtrace-cleaning algorithmic regression, an expensive
|
|
121
121
|
suggestion lookup, or a serialiser change), surface it for review.
|
|
122
|
+
|
|
123
|
+
## Story 9.9 re-point (NFR21 — route-driven dispatch)
|
|
124
|
+
|
|
125
|
+
The v1 synthetic endpoint + `ruact_action` DSL were demolished in Story 9.9, so
|
|
126
|
+
the bench is re-pointed at REAL routes: a non-GET REST route (`POST /posts`) on a
|
|
127
|
+
`include Ruact::Server` controller (Bucket-2 JSON), the multipart `<form action>`
|
|
128
|
+
shape on a plain route, and a `ruact_queries`-mounted query route (`GET /q/recent`).
|
|
129
|
+
|
|
130
|
+
NOTE (D2): the v2 path is a real Rails route through the full router + the
|
|
131
|
+
`Ruact::Server` concern — a different code path than the deleted v1 synthetic
|
|
132
|
+
endpoint, so the per-call delta vs. plain is NOT apples-to-apples with the v1
|
|
133
|
+
baseline above. The honest NFR21 gate is "p50/p95 < 20 ms holds."
|
|
134
|
+
|
|
135
|
+
| Metric | Value |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| Target | server-function dispatch p50/p95 < 20 ms per call |
|
|
138
|
+
| Status | PASS |
|
|
139
|
+
|
|
140
|
+
| Scenario | p50 | p95 |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| `Ruact::Server` JSON dispatch (`POST /posts`, `Post.create!`) | ~0.79 ms | ~1.2 ms |
|
|
143
|
+
| Plain controller action (same `Post.create!`) | ~0.78 ms | ~1.22 ms |
|
|
144
|
+
| Per-call overhead (p50) | ~+0.01 ms | — |
|
|
145
|
+
| Multipart (`<form action>` shape) | ~0.86 ms | ~1.37 ms |
|
|
146
|
+
| Query (`GET /q/recent`, `ruact_queries`) | ~4.23 ms | ~5.90 ms |
|
|
147
|
+
|
|
148
|
+
Local reference run (2026-06-15, rbenv Ruby 3.4.5, Rails 8.1, 1000 samples).
|
|
149
|
+
The query path is heavier than the mutation path (it boots the gem's internal
|
|
150
|
+
query dispatch controller + sanitizes params) but stays comfortably < 20 ms.
|