Skip to content

North Star: serious Rails apps on Roundhouse and Spinel (Conduit, then BitterClip) #1

Open

Description

@bunnykong

North Star

Serious Rails apps, compiled with Roundhouse and Spinel and running in production. First Conduit, a public RealWorld API app, then BitterClip, SheetGenius's production Rails app on PostgreSQL with pgvector, both from unpatched upstream. The reason: keep app logic in Ruby, where it stays dense and easy to change, at compiled speed instead of rewriting parts in Rust.

Countdown

BitterClip  ██████▎               1,125 check errors to go    3,584 → 3,903 → 3,140 → 3,148 → 1,102 → 1,125
Conduit     █████████████            11 local patches to go   12 → 11 → 10 → 11
How the countdown is measured
Date Roundhouse main Errors Change
Oct 9 a28539b6 3,584 start
Oct 10 c210f226 3,903 +319; both Roundhouse and BitterClip moved
Oct 15 15019a9a 3,140 −763
Oct 16 0c4327e8 3,148 +8; both moved; flat against October 15
Oct 21 1c84d346 1,102 −2,046; #962 merged; BitterClip unchanged since Oct 16
Oct 22 14eb0e87 1,125 +23; upstream's check now reaches more code (notes 502 → 190), so errors rose while unsupported-constant and ActiveRecord::Base errors fell

Each point runs roundhouse check from Roundhouse main against BitterClip's own latest main that day, so changes to the app move the count too. Only aggregate numbers are published.

The countdown restarted on October 9. The earlier series ran on a copy of BitterClip frozen on October 4 and reached 743 errors on October 6. Since then the check has begun reaching the app's service objects, which held 2,949 of the 3,584 errors on October 9, while controllers, models and views went from 726 errors to 477 (the app changed too).

The 3,903 errors of October 10 fall into 158 root causes, and the largest 20 cover 88%. More than half (2,152) are references to exception classes defined by assigning Class.new to a constant, which the check doesn't register as classes yet. Another 487 are model methods called on the result of reload or lock!; a local run with #730 removes 486 of them. 2,031 of the 2,152 sit in service objects.

Zero check errors is the first of three stages. Next come a native build of the app, then its own test suite passing against that build.

On October 22 the count is 1,125, up 23 from October 21, with BitterClip unchanged. Upstream's check now reaches more code: notes on known gaps fell from 502 to 190, so errors rose even as unsupported-constant and ActiveRecord::Base errors fell.

Conduit counts the local Roundhouse patches its build applies (v6: 12, v7: 11, v8: 10, v9: 11), and it needs one Spinel patch. Its v9 build already compiles natively on Ruby 4.0.7 and passes its full production gate with those patches. Four of them, Roundhouse 82, 83 and 84 and Spinel 91, fix upstream regressions the rebuild found. Patch 00 is now covered upstream (#1078 merged), so 11 remain in the working series: 10 Roundhouse patches and the Spinel one.

At a glance: 37 Roundhouse, 21 Spinel and 6 spinel-pg PRs merged upstream · 6 Roundhouse PRs (2 drafts) open · 0 blockers · updated 2026-10-22, GitHub checked 2026-10-22 00:56 UTC

What would help most

Reviews. 6 PRs are open; each workstream below marks them in review or draft.

Greg Molnar offered to review the HTTP fixes. PRs from this work merge only as described under How this work runs, so a comment on any of them holds it.

Open questions. Two questions, mainly for Sam; other maintainers' views are welcome meanwhile. Until they're settled, these patches stay local, and nothing else waits on them.

  • Raw WebSocket (patch 20). Conduit's shared test client speaks raw WebSocket JSON rather than Action Cable messages, so the app uses faye-websocket. Is a small hook that hands a raw socket to Tep welcome upstream, or should this stay in the app?
  • Boot hooks and jobs (patch 30). Conduit recovers pending exports in config.after_initialize. Should compiled apps run after_initialize blocks at boot and give each in-process job its own database connection, or is another direction planned?

Workstreams

Each workstream lists its PRs and issues as a checklist: checked when landed or fixed, unchecked while in review, draft, open or next. PR numbers are rubys/roundhouse unless prefixed.

Analysis at app scale

Goal: roundhouse check reports zero errors on BitterClip, the countdown above. · Conduit patches: 80, 84

  • #518 — Large-app analysis needed more than 68 GiB after #477; fixed on main, and the check now finishes in about 4 GiB
  • #617 — An RFC to make the analyzer's fixpoint settle: shared types, monotone rules, recursive types by origin, and an ordered worklist · open
  • #657 — Opt-in canaries show whether the whole-program fixpoint settled (the RFC's first stage)
  • #656 — A copied type shares its pieces instead of duplicating them (the first half of the RFC's second stage)
  • #669 — The research behind the RFC, in the open: each open problem with a check and every attempt; other contributors have claimed work from it · draft
  • Literal model exception factories define nested exception classes, a construct among BitterClip's unsupported errors (built and reviewed) · next
  • Re-measure after each fix and attribute each drop to the PRs behind it · next

Related: Upstream work on the same errors: #503 by tobi (constant resolution from a large internal codebase) and #521 by thomasklemm (return types that oscillate between inference rounds).

Rails behavior in emitted code

Goal: Emitted code behaves like Rails 8.1 for what Conduit and BitterClip use: models, relations, validations and the standard library. · Conduit patches: 60, 61, 71, 74, 75, 81, 83

  • #427 — Struct, Mutex and JSON::ParserError resolve again, retiring patch 75
  • #289 — super in a model's password writer reaches has_secure_password's writer
  • #441 — ActiveSupport::SecurityUtils.secure_compare runs in emitted code
  • #494 — Model find_by with a runtime Hash uses relation predicates, so nil becomes IS NULL and arrays become IN
  • #288, #260, #269 — +"" stays an unfrozen copy, operand parentheses survive, and a class nested in a controller reopens it
  • #442 — Class-level instance variables keep their initial values instead of reading nil
  • #492 — An explicit nil reaches the has_secure_password writers in new, update and update!
  • #493 — A scoped exists? applies Hash conditions instead of reading the Hash as a primary key
  • #497 — Queue, SizedQueue, Thread::Mutex, Comparable and six related names resolve again after #279
  • read_attribute reads known schema columns · next
  • String#first takes characters, not bytes · next
  • Class delete_all returns the affected row count · next
  • #671 — with_lock reloads the record and runs its block in a transaction
  • #918 — errors.of_kind? keeps symbolic error kinds
  • The rest of patch 74: integer serialization and bounds · next
  • #730 — reload and lock! keep the receiver's type, fixing #721

Related: #471 by roger-wilco-forever (Concern class_attribute macros) shares class-body emission order with #442; #438 by dchuk casts request IDs before typed finders.

HTTP serving and API mode

Goal: Compiled API apps serve HTTP correctly through Tep, the server in Roundhouse's Spinel runtime. · Conduit patches: 10, 20

  • #338 — Controllers under ActionController::API dispatch
  • #259, #304 — Apps without views or a root route build
  • #476 — Security fixes, including the raw-where SQL fix first reported privately as #464
  • #465 — Requests with Transfer-Encoding get 400, and the connection closes
  • #466 — Invalid or conflicting Content-Length gets 400, and the connection closes
  • #467 — A body that ends before its Content-Length isn't dispatched
  • #468 — Bytes past a body's end stay for the next request on the connection
  • #498 — Status lines read 409 Conflict and 422 Unprocessable Content, as in Rack
  • #1198 — response.set_header writes a header, and render and redirect_to accept a computed Integer status (patch 10) · in review
  • #1201 — ActionDispatch::Http::Parameters::ParseError resolves, and a subclass's rescue_from wins over its ancestor's (patch 10) · in review
  • request.get_header reads Rack env values · next
  • An unhandled ActionController::BadRequest returns 400 · next

Related: Greg Molnar offered to review the HTTP fixes; his limit/order and header fixes landed in #476.

Native compilation with Spinel

Goal: Emitted apps compile natively on Spinel master with no Spinel patches.

Related: spinel#7462 by amatsuda fixed the earlier lv_line error in native API builds.

Database: prepared statements, then PostgreSQL

Goal: Compiled apps run on PostgreSQL with reusable prepared statements, as BitterClip needs. · Conduit patches: 00, 50, 70, 72, 82

  • #254 — Schema DDL renders per SQL dialect, stage (a) of #91
  • #291, #292, #297, #299 — PostgreSQL schemas ingest: serial and hash-valued keys, partial unique indexes, structure.sql and limit: 8 as bigint
  • #378, #380, #402, #403 — Bound reads: a varying-bind gate, cache recency, one statement per live reader and exact values
  • spinel-pg#2, spinel-pg#3, spinel-pg#4, spinel-pg#5, spinel-pg#9, spinel-pg#10 — spinel-pg: the extended query protocol, prepared statements, UTF-8, batches and SQLSTATE errors
  • #404 — JRuby binds typed JDBC parameters
  • #405 — Binds on by default for Spinel, opt-in for CRuby and JRuby
  • Stage (b), part 1: --database selection and dialect plumbing (built and reviewed locally) · next
  • Stage (b), part 2: the CRuby pg runtime with returned keys · next
  • Stage (c): the Spinel runtime over spinel-pg, by eddygarcas · next

Related: #12 tracks the prepared-statement design, and #91 is the PostgreSQL design thread.

Learnings

Findings worth keeping, newest first.

  • 2026-10-10 — BitterClip's check errors are highly concentrated: two causes make up two thirds of them, exception classes defined with Class.new (55%) and model methods called after reload or lock! (12%, fixed by #730). Zero is a matter of a few broad repairs, then a tail.
  • 2026-10-09 — Rebuilding Conduit on current heads is also a regression detector: v8 needed two new patches for upstream regressions, in return stabilization and in reload typing, while #492 and four other merges trimmed or retired local work.
  • 2026-10-09 — matz fixed all ten re-verified Spinel reports within two hours of filing (spinel#8199 through spinel#8208), each with its own commit and regression test. Small reproductions, re-run on current master before filing, get fixed fast.
  • 2026-10-09 — BitterClip's check now reaches its service objects, which hold 2,949 of its 3,584 errors. Three quarters of all its errors are references to constants the check doesn't support yet, from stdlib modules such as FileUtils and Open3 and library exception classes to the app's own classes.
  • 2026-10-08 — The large-app memory blowup came from recursive types unrolled one level deeper each round and copied each time. Sharing their pieces and storing recursion as references to where it came from bring the large app to 3.0 GiB, and with an ordered worklist every loop settles on six apps; the same answer in any order is still open.
  • 2026-10-07 — Spinel treats every string literal as frozen, so appending to a bare "" raises FrozenError where CRuby 4.0.7 allows it; mutable strings in emitted code need +"" or .dup, which #288 preserves. The instance-variable part of spinel#7834 was fixed upstream about an hour after filing.
  • 2026-10-07 — On Spinel master, the only C error left when an emitted API app compiles natively is a +"" store (spinel#7833); the earlier lv_line error was fixed by spinel#7462.
  • 2026-10-07 — One recursive helper can dominate large-app analysis: since #477, extra inference rounds multiply a recursive tuple type, and BitterClip's check needs more than 68 GiB instead of 24 GiB (#518).
  • 2026-10-06 — Three quarters of BitterClip's check errors are unresolved method calls or unsupported constructs, and about half sit in controllers, so dispatch and controller coverage move the countdown most.
  • 2026-10-06 — A PR's CI runs only the jobs its changed paths select, so a break in another target can first show on main. PRs from this work also run the full default suite before review.
  • 2026-10-05 — Spinel moves fast: small standalone reproductions and PRs are usually handled within a day (spinel#7690), which is why Conduit needs no Spinel patches.
  • 2026-10-04 — Binds make complete database requests 2–7.5× faster on Spinel. Reusing a statement while another reader is still iterating it silently returns wrong rows, so each live reader now owns its statement (#402).
  • 2026-10-03 — Patches are coarse: patches 10 and 60 together cover about 35 separate behaviors, so progress here is counted in PRs and behaviors, and a patch retires only when a rebuilt Conduit passes without it.

How this work runs

  • One behavior per PR, with a regression that fails on main and passes with the change, checked against Rails 8.1 and by running the emitted code on CRuby and native Spinel.
  • Independent PRs open in parallel, about three a day; stacked PRs go up one at a time, and the open one is fixed before anything stacked on it advances. Pushes and rebases are staggered so CI doesn't queue behind them (#273).
  • Upstream's typing checks stay green; when a change moves a tracked count, its PR says by how much and why.
  • PRs from this work are merged by their author only after CI passes, an independent AI review and every bot thread are resolved, and about 24 hours pass without objection. A comment on any PR holds it. Other contributors' PRs are never merged from here.
  • Work others have listed is coordinated before any overlap: thomasklemm's large-app worklist (thomasklemm/roundhouse#1), and stage (c) of the PostgreSQL lane, which belongs to eddygarcas.
  • The work is done by AI coding agents (Claude Code and Codex) for SheetGenius; each PR lists the tests actually run and their limits.
  • BitterClip is private, so only aggregate numbers are published.
  • When plans change, this page changes first. Milestones are posted as comments below.

Reference

Glossary
  • Roundhouse — Turns a Rails app into plain Ruby and other target languages; its Ruby output is what Spinel compiles.
  • Spinel — Matz's ahead-of-time compiler from Ruby to C, producing a native binary.
  • Tep — The HTTP server in Roundhouse's Spinel runtime.
  • spinel-pg — A pure-Ruby PostgreSQL client that Spinel compiles.
  • Conduit — A Rails API app built to the RealWorld spec; the first, public milestone.
  • BitterClip — SheetGenius's production Rails app on PostgreSQL with pgvector; the second milestone. Private, so only aggregate numbers appear here.
  • roundhouse check — Roundhouse's analysis pass: it reports errors and warnings without emitting code.
  • Local patch — A change to Roundhouse that Conduit's build applies on top of upstream; it retires when a rebuilt Conduit passes the same checks without it.
  • Binds — Placeholders such as WHERE id = ? with values passed separately, so a prepared statement can be reused across values.
  • Stages (a), (b), (c) — The PostgreSQL lane: DDL dialects, the CRuby pg runtime, and the Spinel runtime over spinel-pg.
Conduit patch inventory (v9)

A patch retires only when a new Conduit build passes the same app checks without it.

Patch Covers State Workstream
75 Standard-library constants Retired Rails behavior in emitted code
61 An explicitly nil password writer Retired Rails behavior in emitted code
10 JSON controllers, headers and errors Trimmed HTTP serving and API mode
50 PostgreSQL runtime, schema and codecs Trimmed Database: prepared statements, then PostgreSQL
70, 72 PostgreSQL placeholder rewrite and async statement cache, optimizations the gate passes without Retired Database: prepared statements, then PostgreSQL
60 Class state, relations and uniqueness Trimmed Rails behavior in emitted code
74 Integer query bounds Trimmed Rails behavior in emitted code
71 Record locks and error predicates Retired Rails behavior in emitted code
20 Raw WebSocket API Trimmed Open questions
30 Boot hooks and job drain Trimmed Open questions
00 Interval columns, now covered upstream by #1078 Remaining Database: prepared statements, then PostgreSQL
80 Gradual return arms survive stabilization, working around an upstream regression Remaining Analysis at app scale
81 reload keeps its concrete model receiver, working around an upstream regression Retired Rails behavior in emitted code
82 A bulk UPDATE quotes its table name once, working around an upstream regression Remaining Database: prepared statements, then PostgreSQL
83 A model constructor formats a Time before storing it, working around an upstream regression Remaining Rails behavior in emitted code
84 A parameter stays untyped when one of its arguments is untyped, working around an upstream regression Remaining Analysis at app scale
Builds and evidence

Conduit's v9 build uses Ruby 4.0.7, Roundhouse 1c84d346 and Spinel 6ca6d451b, and passes the full production gate. Known limits: eight responses differ from the Rails reference, one more than v8 (a malformed JSON body gets an HTML 400 instead of the app's JSON error); v8's validator and typing checks weren't rerun; and this build wasn't benchmarked.

Since v8, the patch diff went from +2,874/−223 lines in 74 files to +2,481/−221 in 66. #918 retired patch 71, #730 retired patch 81, and upstream fixes trimmed patches 10, 20, 50, 60 and 74. Three new patches work around upstream regressions the rebuild found: 82 quotes a bulk UPDATE's table name once, 83 formats a Time before a model constructor stores it, and 84 keeps a parameter untyped when one of its arguments is untyped. Spinel 91 keeps a possible nil in a Hash return type, which a recent Spinel change dropped.

The app itself differs from the Rails original in 16 files (+180/−44). Two of those changes work around missing support: Array#reject! dispatch in Spinel and association(:name) analysis in Roundhouse.

PostgreSQL lane

#91 is the design thread, and the work is split into three stages. Stage (a), DDL dialects, landed in #254. Stage (b) is part of this work: -d/--database overriding database.yml, then the synchronous pg runtime, returned keys and a UUID fixture, with SQLite output unchanged. Its first part, selection and dialect plumbing, is built and reviewed locally and opens after the prepared-statement series. Stage (c), the Spinel runtime over spinel-pg, is eddygarcas's. Node/TypeScript PostgreSQL is outside this work.

Returned keys are agreed: both runtimes implement Db.exec_returning(sql) → handle, which exposes the affected-row count and invalidates the query cache like exec. In spinel-pg, the extended query protocol, UTF-8, result boundaries and type OIDs are merged, and spinel-pg#10 adds pg-compatible SQLSTATE errors, so the Rails error mapping can be shared code.

A local profile of the v6 build found PostgreSQL near its two-core quota on the feed, article, comments and tags pages, and on the first three, planning most queries took longer than running them. That's why statement reuse is being tested; a speedup isn't measured yet.

This page is generated from structured data and checked against GitHub before each update. Milestones are posted as comments below.

Activity

  1. pinned this issue on Oct 5, 2026
  2. changed the title [-]Tracking: Conduit to zero local compiler patches, then BitterClip[/-] [+]Tracking: compiling Conduit, then BitterClip, with no local patches[/+] on Oct 5, 2026
  3. changed the title [-]Tracking: compiling Conduit, then BitterClip, with no local patches[/-] [+]North Star: serious Rails apps on Roundhouse and Spinel (Conduit, then BitterClip)[/+] on Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions