ruact 0.0.10 → 0.0.12

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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +16 -6
  5. data/RELEASING.md +125 -139
  6. data/lib/ruact/packaging.rb +68 -0
  7. data/lib/ruact/version.rb +1 -1
  8. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  9. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  10. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  11. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  12. metadata +6 -107
  13. data/.codecov.yml +0 -31
  14. data/.github/workflows/ci.yml +0 -284
  15. data/.github/workflows/server-functions-bench.yml +0 -54
  16. data/.rubocop.yml +0 -107
  17. data/.rubocop_todo.yml +0 -63
  18. data/Rakefile +0 -10
  19. data/bench/server_functions_dispatch_bench.rb +0 -276
  20. data/bench/server_functions_dispatch_bench.results.md +0 -150
  21. data/docs/internal/README.md +0 -9
  22. data/docs/internal/decisions/server-functions-api.md +0 -2236
  23. data/spec/benchmarks/baseline.json +0 -1
  24. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -92
  25. data/spec/fixtures/flight/README.md +0 -136
  26. data/spec/fixtures/flight/array.txt +0 -1
  27. data/spec/fixtures/flight/as_json_object.txt +0 -2
  28. data/spec/fixtures/flight/bigint.txt +0 -1
  29. data/spec/fixtures/flight/boolean_false.txt +0 -1
  30. data/spec/fixtures/flight/boolean_true.txt +0 -1
  31. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  32. data/spec/fixtures/flight/client_reference.txt +0 -2
  33. data/spec/fixtures/flight/hash.txt +0 -1
  34. data/spec/fixtures/flight/infinity.txt +0 -1
  35. data/spec/fixtures/flight/nan.txt +0 -1
  36. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  37. data/spec/fixtures/flight/nil.txt +0 -1
  38. data/spec/fixtures/flight/number_float.txt +0 -1
  39. data/spec/fixtures/flight/number_integer.txt +0 -1
  40. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  41. data/spec/fixtures/flight/redirect_row.txt +0 -1
  42. data/spec/fixtures/flight/serializable_object.txt +0 -2
  43. data/spec/fixtures/flight/string_basic.txt +0 -1
  44. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  45. data/spec/fixtures/flight/undefined.txt +0 -1
  46. data/spec/fixtures/readme/children-error.html.erb +0 -3
  47. data/spec/fixtures/readme/children-error.txt +0 -1
  48. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  49. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  50. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  51. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  52. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  53. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  54. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  55. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  56. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  57. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  58. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  59. data/spec/readme_demo_message_spec.rb +0 -67
  60. data/spec/readme_spec.rb +0 -282
  61. data/spec/ruact/client_manifest_spec.rb +0 -270
  62. data/spec/ruact/component_contract_spec.rb +0 -119
  63. data/spec/ruact/configuration_spec.rb +0 -518
  64. data/spec/ruact/controller_request_spec.rb +0 -671
  65. data/spec/ruact/controller_spec.rb +0 -343
  66. data/spec/ruact/doctor_spec.rb +0 -769
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  68. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  69. data/spec/ruact/errors_spec.rb +0 -93
  70. data/spec/ruact/flight/renderer_spec.rb +0 -133
  71. data/spec/ruact/flight/serializer_spec.rb +0 -494
  72. data/spec/ruact/html_converter_spec.rb +0 -375
  73. data/spec/ruact/install_generator_spec.rb +0 -1549
  74. data/spec/ruact/layout_source_spec.rb +0 -108
  75. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  76. data/spec/ruact/query_request_spec.rb +0 -706
  77. data/spec/ruact/query_spec.rb +0 -105
  78. data/spec/ruact/railtie_spec.rb +0 -155
  79. data/spec/ruact/render_context_spec.rb +0 -58
  80. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  81. data/spec/ruact/render_pipeline_spec.rb +0 -928
  82. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  83. data/spec/ruact/serializable_spec.rb +0 -179
  84. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  85. data/spec/ruact/server_function_name_spec.rb +0 -53
  86. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  87. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  88. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  89. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  90. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  91. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  92. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  93. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  94. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  95. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  96. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  97. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  98. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  99. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  100. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  101. data/spec/ruact/server_spec.rb +0 -179
  102. data/spec/ruact/server_upload_request_spec.rb +0 -311
  103. data/spec/ruact/signed_references_spec.rb +0 -164
  104. data/spec/ruact/string_distance_spec.rb +0 -38
  105. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  106. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  107. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  108. data/spec/ruact/validation_errors_spec.rb +0 -116
  109. data/spec/ruact/view_helper_spec.rb +0 -131
  110. data/spec/spec_helper.rb +0 -77
  111. data/spec/support/fixtures/pixel.png +0 -0
  112. data/spec/support/flight_wire_parser.rb +0 -21
  113. data/spec/support/flight_wire_parser_spec.rb +0 -93
  114. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  115. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  116. data/spec/support/rails_stub.rb +0 -115
@@ -1,276 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Story 9.9 — NFR21 dispatch-overhead benchmark, re-pointed at REAL routes.
4
- #
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:
8
- #
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`.
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."
22
- #
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.
26
- #
27
- # Run with:
28
- # bundle exec ruby bench/server_functions_dispatch_bench.rb
29
- #
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
-
33
- require "bundler/setup"
34
- require "benchmark/ips"
35
-
36
- require "action_controller/railtie"
37
- require "active_record"
38
- require "sqlite3"
39
- require "rack/test"
40
-
41
- require "ruact"
42
- require "ruact/controller"
43
- require "ruact/server"
44
- require "ruact/routing"
45
- require "ruact/query"
46
-
47
- ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
48
- ActiveRecord::Schema.verbose = false
49
- ActiveRecord::Schema.define do
50
- create_table :posts, force: true do |t|
51
- t.string :title, null: false
52
- t.text :body
53
- t.timestamps
54
- end
55
- end
56
-
57
- class Post < ActiveRecord::Base
58
- validates :title, presence: true
59
- end
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
-
65
- class BenchApp < Rails::Application
66
- config.eager_load = false
67
- config.consider_all_requests_local = false
68
- config.action_controller.perform_caching = false
69
- config.action_dispatch.show_exceptions = :none
70
- config.logger = Logger.new(IO::NULL)
71
- config.active_support.deprecation = :silence
72
- config.secret_key_base = "x" * 64
73
- config.hosts.clear if config.respond_to?(:hosts)
74
- end
75
-
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
81
-
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 })
86
- end
87
-
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
101
- payload = JSON.parse(request.raw_post)
102
- post = Post.create!(title: payload["title"], body: payload["body"])
103
- render(json: { id: post.id, title: post.title })
104
- end
105
-
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
110
- end
111
-
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)
116
- end
117
- end
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
-
127
- BenchApp.instance.initialize!
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
131
-
132
- include Rack::Test::Methods
133
-
134
- def app = BenchApp.instance
135
-
136
- counter = 0
137
- body_for = lambda do
138
- counter += 1
139
- { title: "Post #{counter}", body: "body" }.to_json
140
- end
141
- json_headers = { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json" }
142
-
143
- post("/posts", body_for.call, json_headers)
144
- unless last_response.status == 200
145
- raise "ruact server dispatch broken (status=#{last_response.status} body=#{last_response.body})"
146
- end
147
-
148
- post("/plain", body_for.call, json_headers)
149
- raise "plain dispatch broken (status=#{last_response.status})" unless last_response.status == 200
150
-
151
- get("/q/recent", {}, { "HTTP_ACCEPT" => "application/json" })
152
- raise "query dispatch broken (status=#{last_response.status})" unless last_response.status == 200
153
-
154
- Benchmark.ips do |x|
155
- x.config(time: 3, warmup: 1)
156
-
157
- x.report("Ruact::Server dispatch (POST /posts, Post.create!)") do
158
- post("/posts", body_for.call, json_headers)
159
- end
160
-
161
- x.report("plain controller action (Post.create!)") do
162
- post("/plain", body_for.call, json_headers)
163
- end
164
-
165
- x.compare!
166
- end
167
-
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).
171
- def time_one_seconds
172
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
- yield
174
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
175
- end
176
-
177
- def sample_times(count, &block)
178
- Array.new(count) { time_one_seconds(&block) }.sort
179
- end
180
-
181
- def percentile(sorted, pct)
182
- idx = (sorted.size * pct / 100).clamp(0, sorted.size - 1)
183
- sorted[idx]
184
- end
185
-
186
- SAMPLES = 1000
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) }
189
-
190
- ruact_p50 = percentile(ruact_samples, 50) * 1000
191
- plain_p50 = percentile(plain_samples, 50) * 1000
192
- ruact_p95 = percentile(ruact_samples, 95) * 1000
193
- plain_p95 = percentile(plain_samples, 95) * 1000
194
- ruact_total = ruact_samples.sum
195
- plain_total = plain_samples.sum
196
-
197
- overhead_p50 = (ruact_p50 - plain_p50).round(3)
198
- overhead_p95 = (ruact_p95 - plain_p95).round(3)
199
-
200
- puts ""
201
- puts "#{SAMPLES} requests (Post.create! body):"
202
- puts " Ruact::Server dispatch: total=#{(ruact_total * 1000).round(1)}ms p50=#{ruact_p50.round(3)}ms p95=#{ruact_p95.round(3)}ms"
203
- puts " plain controller action: total=#{(plain_total * 1000).round(1)}ms p50=#{plain_p50.round(3)}ms p95=#{plain_p95.round(3)}ms"
204
- puts " per-call overhead (p50): +#{overhead_p50}ms"
205
- puts " per-call overhead (p95): +#{overhead_p95}ms"
206
- 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'})"
210
-
211
- # ----------------------------------------------------------------------------
212
- # Multipart (`<form action={fn}>`) dispatch overhead on the same route.
213
- # ----------------------------------------------------------------------------
214
-
215
- require "securerandom"
216
-
217
- def multipart_body(title, body)
218
- boundary = "---ruact-bench-#{SecureRandom.hex(8)}"
219
- data = +""
220
- data << "--#{boundary}\r\n"
221
- data << "Content-Disposition: form-data; name=\"title\"\r\n\r\n"
222
- data << "#{title}\r\n"
223
- data << "--#{boundary}\r\n"
224
- data << "Content-Disposition: form-data; name=\"body\"\r\n\r\n"
225
- data << "#{body}\r\n"
226
- data << "--#{boundary}--\r\n"
227
- [data, boundary]
228
- end
229
-
230
- multipart_headers = lambda do |boundary|
231
- { "CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}" }
232
- end
233
-
234
- multipart_counter = 0
235
- multipart_post = lambda do
236
- multipart_counter += 1
237
- data, boundary = multipart_body("MP Post #{multipart_counter}", "multipart body")
238
- post("/posts_mp", data, multipart_headers.call(boundary))
239
- end
240
-
241
- multipart_post.call
242
- raise "multipart broken (status=#{last_response.status})" unless last_response.status == 200
243
-
244
- multipart_samples = sample_times(SAMPLES) { multipart_post.call }
245
- mp_p50 = percentile(multipart_samples, 50) * 1000
246
- mp_p95 = percentile(multipart_samples, 95) * 1000
247
- mp_total_ms = (multipart_samples.sum * 1000).round(1)
248
-
249
- puts ""
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"
252
- 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'})"
256
-
257
- # ----------------------------------------------------------------------------
258
- # Query dispatch overhead — GET /q/<jsId> (a ruact_queries-mounted read).
259
- # ----------------------------------------------------------------------------
260
-
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
264
-
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)
269
-
270
- puts ""
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"
273
- puts ""
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'})"
@@ -1,150 +0,0 @@
1
- # `server_functions_dispatch_bench` results
2
-
3
- Reference numbers for the end-to-end dispatch overhead of `ruact_action`
4
- vs. a plain controller action doing the same work.
5
-
6
- Re-run with `bundle exec ruby bench/server_functions_dispatch_bench.rb`
7
- from the `gem/` directory.
8
-
9
- ## Story 8.1 baseline (AC12 — JSON dispatch overhead)
10
-
11
- | Metric | Value |
12
- | --- | --- |
13
- | Target | median ruact_action overhead < 20 ms per call |
14
- | Status | PASS |
15
-
16
- | Scenario | p50 | p95 |
17
- | --- | --- | --- |
18
- | ruact_action JSON dispatch (`Post.create!`) | ~0.95 ms | ~1.3 ms |
19
- | Plain controller action (same `Post.create!`) | ~0.78 ms | ~1.1 ms |
20
- | Per-call overhead (p50) | +0.17 ms | — |
21
- | Per-call overhead (p95) | — | +0.22 ms |
22
-
23
- ## Story 8.2 baseline (AC11 — multipart dispatch overhead)
24
-
25
- | Metric | Value |
26
- | --- | --- |
27
- | Target | multipart median ≤ 1.2× JSON median |
28
- | Status | PASS |
29
-
30
- | Scenario | p50 | p95 |
31
- | --- | --- | --- |
32
- | ruact_action multipart dispatch (`<form action={fn}>` shape) | ~1.03 ms | ~1.5 ms |
33
- | ruact_action JSON dispatch (baseline) | ~0.95 ms | ~1.3 ms |
34
- | Multipart vs. JSON p50 factor | **1.08×** | — |
35
-
36
- **Interpretation.** Multipart parsing is heavier than JSON parsing (Rails'
37
- multipart parser walks the boundary stream and allocates a temp file per
38
- non-text part), but for sub-1KB bodies typical of `<form action={fn}>` the
39
- delta is below noise versus the JSON path. The AC11 tolerance of 1.2×
40
- covers heavier real-world bodies (file uploads — Story 8.5 will revisit
41
- the bench with multipart bodies that include actual file blobs).
42
-
43
- ## Story 8.3 baseline (AC10 — standalone-host dispatch overhead)
44
-
45
- | Metric | Value |
46
- | --- | --- |
47
- | Target | standalone median within 0.95× .. 1.05× of JSON controller baseline |
48
- | Status | WITHIN ORDER-OF-MAGNITUDE (see interpretation) |
49
-
50
- | Scenario | p50 | p95 |
51
- | --- | --- | --- |
52
- | Standalone dispatch (`extend Ruact::ServerAction` + `<form action>`-equivalent JSON body) | ~1.25–1.40 ms | ~2.8–3.5 ms |
53
- | ruact_action JSON dispatch (controller baseline) | ~1.06–1.17 ms | ~1.6–2.3 ms |
54
- | Standalone vs. controller p50 factor (observed range) | **1.07× – 1.32×** | — |
55
-
56
- **Interpretation.** Two reference runs on 2026-05-17 (2024 MacBook, Ruby
57
- 3.4.5, Rails 8.1.3, no isolation — typical dev workstation noise):
58
-
59
- | Run | controller p50 | standalone p50 | factor |
60
- | --- | --- | --- | --- |
61
- | 1 | 1.172 ms | 1.252 ms | 1.068× |
62
- | 2 | 1.062 ms | 1.396 ms | 1.315× |
63
-
64
- The factor swings substantially across consecutive runs on the same
65
- hardware (run 2's controller p50 of 1.062 ms is faster than run 1's
66
- 1.172 ms, while standalone shifted the other way). Both observations
67
- sit comfortably inside the AC10 "catch a 10× regression" envelope.
68
-
69
- The strict 0.95×–1.05× band the AC literal calls out is unrealistic on
70
- a noisy laptop without proper isolation (no Docker, no `taskset`, no
71
- CPU governor pinning). The numbers DO confirm the design hypothesis:
72
- the standalone path's `process_action`-bypass + lighter context
73
- allocation balances out the StandaloneContext setup overhead, leaving
74
- the two paths within sub-millisecond noise of each other.
75
-
76
- If a future run reports `factor > 2.0×`, that's the signal a real
77
- regression has landed (the StandaloneDispatcher allocated something
78
- heavy in the hot path, or the conditional CSRF callback started doing
79
- real work for the controller branch). The 10× catch-band remains the
80
- PR-comment alert threshold; tighten the gate locally only when running
81
- under controlled isolation.
82
-
83
- ## Hardware reference
84
-
85
- Numbers captured 2026-05-17 on a 2024 MacBook (Ruby 3.4, Rails 8.0). Your
86
- local numbers vary by ±20% depending on CPU thermal state and background
87
- load. The PASS/FAIL gate is the ratio (multipart vs. JSON, ruact vs.
88
- plain), not absolute milliseconds — the ratios are stable across
89
- hardware while absolute numbers are not.
90
-
91
- ## CI nightly
92
-
93
- `gem/.github/workflows/server-functions-bench.yml` runs the bench on the
94
- gem's CI host and posts the numbers as a non-blocking workflow summary.
95
- A 10× regression in either ratio is the alert threshold for human
96
- inspection; no merge gate.
97
-
98
- ## Story 8.4 baseline (2026-05-18)
99
-
100
- Added an `error_path_overhead` scenario: an action that always raises
101
- `RuntimeError("forced")` end-to-end through the new
102
- `EndpointController#__ruact_render_action_error` rescue chain
103
- (`rescue_from StandardError`). Captures p50/p95 of the failing path so
104
- future regressions (e.g., adding an expensive serializer step inside
105
- `ErrorPayload.build`) surface in nightly numbers.
106
-
107
- | Scenario | p50 | p95 |
108
- | --------------------------- | -------- | ------- |
109
- | ruact_action (Post.create!) | ~1.02 ms | ~1.5 ms |
110
- | error-path (raise → 500) | ~0.83 ms | ~1.3 ms |
111
-
112
- The error path is COMPARABLE to (slightly faster than) the happy path
113
- because the raised exception short-circuits before reaching ActiveRecord's
114
- write path — `ErrorPayload.build` + `BacktraceCleaner.split` + the JSON
115
- serialisation are cheaper than the `Post.create!` insert + validation
116
- roundtrip. This is informational only; no regression band — the happy-path
117
- scenarios above are the load-bearing gates.
118
-
119
- If a future change pushes the error path above ~5 ms p50 without a clear
120
- reason (e.g., a backtrace-cleaning algorithmic regression, an expensive
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.
@@ -1,9 +0,0 @@
1
- # `gem/docs/internal/`
2
-
3
- Append-only Architecture Decision Records and design notes about gem
4
- internals — distinct from user-facing API docs (in `website/`) and from
5
- workspace planning artifacts (`_bmad-output/`, monorepo-only).
6
-
7
- - [`decisions/`](./decisions) — one ADR per file; revisit via dated addenda
8
- at the bottom, never by rewriting the body. Add a new ADR here when a
9
- story locks an API or mechanism downstream consumers will rely on.