Performance defaults
The performance decisions Magik makes on its users' behalf: what it does without being asked, why, what each one costs, and how to turn it off.
Status: planned. Nothing on this page is implemented — there is no serializer, no connection pool, no index generator, no cache and no paginator. No page in this repo may carry a benchmark number, and this one carries none: every quantity below is either a target with the command that will measure it, or an upstream project's own published claim, attributed. Reviewed 2026-08-26.
Why a framework decides this
Ruby has a long, well-known list of performance techniques. statement_timeout. An index on the foreign key. Keyset pagination. Bound parameters. Bulk insert. Experienced people apply them; everyone else never hears about them, and finds out on the day a query that was fine at 10,000 rows is not fine at 10,000,000.
Magik's premise is that the framework makes the decisions its users would not know to make. That premise is sharper here than in most frameworks for the reason in ../idea/07-ai-first.md: the primary developer is an agent, and an agent writes whatever the framework makes easiest, not what a performance-minded human would have chosen. A framework that leaves SELECT * as the path of least resistance will get SELECT *, several thousand times, in code nobody reads line by line.
So the default is the design. This page is the list.
The rule that sorts this page
From ../idea/01-thesis.md: Magik's opinions are concentrated exactly where reversal is impossible, and where reversal is cheap the swap points decide instead. Applied to a performance default, that is a three-way test:
| Tier | The test | What it looks like |
|---|---|---|
| Baked in | reversing it later is a data migration, a correctness bug, or a security incident — or there is no legitimate reason to want the slow version | not configurable. No flag, no option, no call-site override |
| Default on, switchable | the default is right for almost every app, and the minority that needs the other answer needs it for a whole deployment | one use/config line in App.define (../idea/04-swap-points.md), resolved at boot |
| Opt-in | it costs something real — writes, memory, staleness, or an invalidation story — so the spec's cost is opt-in axiom applies | a declaration in app code. Zero cost until written |
The complete classification is the summary table. Where a default did not fit its tier cleanly, or contradicts something the spec already decided, it is in Conflicts and open questions rather than quietly smoothed over.
The stack these defaults are specific to
TruffleRuby · Puma · Sequel · Postgres · htmx. Half the published Ruby performance advice is about a different stack, and applying it here would be worse than applying nothing.
The concurrency primitive is a thread, and on TruffleRuby threads run genuinely in parallel — there is no GVL. That is measured, not assumed: 12-runtime-verification.md, As of 2026-08-26.
What does not apply here
CRuby-specific techniques, named so nobody wastes an afternoon:
| Technique | Why it does not apply |
|---|---|
| YJIT / ZJIT | Ruby's own documentation describes YJIT as "a lightweight, minimalistic Ruby JIT built inside CRuby" (doc/jit/yjit.md). It is a CRuby component, not a portable gem. TruffleRuby has the Graal compiler; there is no --yjit to pass |
jemalloc, MALLOC_ARENA_MAX |
glibc-malloc tuning aimed at CRuby's heap behaviour. TruffleRuby Native uses Native Image's garbage collector, and TruffleRuby on the JVM uses the JVM heap; neither routes Ruby object allocation through libc malloc. No upstream TruffleRuby sentence says this — it is an inference from how the runtime is built, not a quote |
RUBY_GC_* environment variables |
CRuby's GC tuning surface. TruffleRuby's memory guidance is --jvm vs native and the JVM/Native Image heap settings (deploying.md) |
| Bootsnap | caches CRuby bytecode via RubyVM::InstructionSequence, which is CRuby-specific |
| GVL-shaped thread-pool arithmetic | TruffleRuby's README states it "does not have a global interpreter lock and runs both Ruby code and thread-safe native extensions in parallel" (README). The reasoning that produces "5 threads per Puma worker" — thread count as a hedge against a lock — does not transfer. Magik does run Puma; it does not inherit Puma's GVL-shaped arithmetic (§2.1) |
As of 2026-08-26. TruffleRuby-specific behaviour is verified by scripts/probes/runtime.rb and in CI, never asserted from a laptop's CRuby (12-runtime-verification.md).
1. Serialization
JSON is not a corner of the framework. It is the API response body, the job payload, the production log line, the --json rendering of every CLI command and every error, and the --json request trace (06-observability.md). It is on a hot path in four subsystems at once, which is why it gets a section here at all.
One front door — baked in
Framework and app code call Magik::Core::Json.dump / .parse. Nothing anywhere calls JSON.generate, #to_json or MultiJson directly. This is the same one namer rule the swap points use: a codec named at four hundred call sites is a codec that cannot be changed.
Not switchable, because the alternative is not a slower version — it is no seam at all.
The mode is pinned — baked in
Interchange JSON in, interchange JSON out. The seam emits and accepts plain JSON: objects, arrays, strings, numbers, booleans, null. It never emits Ruby type markers and it never reconstructs a Ruby object from something a document claims to be.
That is a security rule dressed as a serialization rule. Codecs in this ecosystem ship object-round-tripping modes, and a payload that can name a class is a payload that can name any class. For an inbound webhook body or a job payload, that is not a performance question at all. So the mode is pinned at the seam and is not an option.
No global monkey-patching of JSON. A codec that takes over the JSON constant's methods process-wide is exactly the action-at-a-distance the boundary rules exist to prevent (02-boundaries.md), and a seam whose behaviour depends on which gem loaded last is not a seam.
Which codec — the stdlib json gem
The codec is the json gem. No C-extension JSON dependency is carried.
The reason is one fact, and it is the reference library's own decision rather than an opinion:
| Fact | Source |
|---|---|
The json gem ships a TruffleRuby-specific pure-Ruby generator, selected by engine: if RUBY_ENGINE == 'truffleruby' … JSON.generator = JSON::TruffleRuby::Generator |
ruby/json lib/json/ext.rb, CHANGES.md |
| TruffleRuby vendors that gem | the engine ships it |
The reference JSON library for the whole Ruby ecosystem decided that on TruffleRuby a pure-Ruby generator compiled by Graal beats its own C extension. "Use the C extension because C is fast" is a CRuby intuition, and it does not survive the move to a JIT that compiles Ruby and interprets C through Sulong. Carrying a C-extension codec would mean adding a native dependency to beat a generator the runtime's own maintainers chose — and it has never been measured here.
The codec remains a seam, because that is the rule for anything reversible (../idea/04-swap-points.md):
App.define :Shop do
use :json, :stdlib # the default, and the only backend shipped
end
| Tier | Decision |
|---|---|
| Baked in | one front door; a pinned mode; no mimic_JSON |
| Default on, switchable | the codec — use :json, …, or MAGIK_JSON_BACKEND, for an app that has measured something better on its own workload |
| Opt-in | nothing |
A named backend that does not load fails at boot with MAGIK_CONFIG_UNKNOWN_BACKEND, per the swap-point rule that candidates never silently degrade. The conformance suite for this seam is a round-trip corpus (unicode, deep nesting, large integers, :money minor units, nil) run against every registered codec — see Measurement.
Where JSON is not used
| Path | Instead |
|---|---|
:money on the wire |
integer minor units plus a currency string. Never a JSON float, on any codec (../idea/02-dsl-surface.md) |
| Redacted fields | redaction runs before serialization, never after. A secret that reaches the serializer has already left the building (07-configuration-and-secrets.md) |
| htmx responses | HTML fragments. There is no client-side store to send JSON to (../idea/05-limits.md) |
2. The database layer
Almost all real SaaS slowness is here. The sections below are ordered by how often they are the answer.
2.1 Connection pooling under a thread pool — baked in
Getting the pool wrong is a correctness bug, not a slow page, so the arithmetic is stated rather than left to the operator.
The mechanism. Sequel's connection pool keys checked-out connections on Sequel.current, whose definition in lib/sequel/core.rb is:
# The current concurrency primitive, Thread.current by default.
def current
Thread.current
end
and whose pool comments describe @allocated as "a hash with thread/fiber keys and connection values for currently allocated connections" (threaded.rb). hold short-circuits on owned_connection(Sequel.current) — re-entrant checkout is keyed on the concurrency primitive.
Puma runs one thread per in-flight request, from a bounded pool. One request, one thread, one Thread.current — so Sequel's default keying is the correct keying, and the pool hands each concurrent request its own connection. Nothing has to be configured to make that true, and Sequel.current must be left alone: magik doctor asserts it resolves to a Thread and fails loudly if some dependency has changed it underneath the app.
The arithmetic.
| Rule | |
|---|---|
| Concurrency ceiling | max_threads × processes — a number you chose, and Puma enforces it |
| Pool size | pool = max_threads. It derives from the server, and any other value is a mistake in one direction or the other |
| Pool smaller than threads | request threads queue on the connection pool and then raise Sequel::PoolTimeout after :pool_timeout (Sequel default 5s). Looks like slowness, reports as an error |
| Pool larger than threads | connections the app can never use, charged against the server's max_connections |
| Overload looks like | requests queue on Puma's backlog; latency rises before anything errors |
| Connections the DB sees | max_threads × processes × hosts |
Sequel's defaults are :max_connections 4 and :pool_timeout 5 seconds (opening databases); on Ruby ≥ 3.2 the default pool class is :timed_queue.
The process multiplier is where TruffleRuby differs from every Puma deployment guide you will read. Puma's clustered mode forks workers, and fork is not available on TruffleRuby — Process.respond_to?(:fork) is false, measured on both builds tested (12-runtime-verification.md). So:
| Puma mode | single mode. One process, one thread pool. workers N does not apply |
| How you get more capacity | more containers. That is the ops story already (../ops/README.md): stateless app servers behind a load balancer |
| Why that is the right shape rather than a concession | clustered mode exists because of the GVL — one process cannot use more than one core. TruffleRuby has no GVL and its threads were measured genuinely parallel (ruby scripts/probes/runtime.rb). One process does use the cores |
| What is still unmeasured | how many concurrent connections one Puma process on TruffleRuby holds. Named as owed work in 12-runtime-verification.md |
Magik's default: the pool is a declared ceiling, and the total is checked, not assumed.
App.define :Shop do
database url: ENV.fetch("DATABASE_URL"),
pool: 8, # in-flight queries per process; = Puma max_threads
pool_timeout: 5 # seconds a request thread waits for a connection
end
magik doctor reads max_connections and superuser_reserved_connections from the server and reports the arithmetic — and, under threads, that pool and Puma's max_threads agree:
pool 8 × web 4 + pool 4 × workers 2 + 4 realtime LISTEN = 44 of 100 max_connections
Realtime is in that sum on purpose: Postgres LISTEN occupies a connection for as long as it listens, which is why ../ops/README.md already lists "LISTEN connections against Postgres" as a thing to watch.
Proposed, not in the catalogue: MAGIK_SCALE_POOL_OVERSUBSCRIBED, severity warning, raised by magik doctor when the arithmetic exceeds what the server will grant.
Beyond that, PgBouncer — and it is not free. PgBouncer's transaction pooling mode marks SET/RESET, LISTEN, WITH HOLD cursors, PREPARE/DEALLOCATE, session-level advisory locks and LOAD as never working, with the docs' own framing that "transaction pooling breaks client expectations of the server by design" (PgBouncer features). Two of those are Magik defaults: SET-based connection setup (§2.3) and LISTEN (the realtime backend). Since PgBouncer 1.21, protocol-level prepared statements do work in transaction mode via max_prepared_statements (PgBouncer config) — but SQL-level PREPARE still does not. A pooler is therefore a deployment topology Magik documents, not a default it ships.
For sizing the database side rather than the app side, the widely used formula is HikariCP's connections = (core_count * 2) + effective_spindle_count (About Pool Sizing) — community-authoritative, not a Postgres document, and cited as such.
2.2 Bound parameters and prepared statements
Bound parameters — baked in. Every value the model DSL puts in a query is a bound parameter. Sequel's pg_auto_parameterize extension "changes Sequel's postgres adapter to automatically parameterize queries by default" (pg_auto_parameterize), and Magik enables it. There is no legitimate reason to want literalised SQL: it is an injection surface, and it defeats plan reuse by producing a different query text per value.
The extension's documented caveats are real and belong here rather than in a surprise: a wrong type guess raises DatabaseError unless you write Sequel.cast(value, type); Postgres allows a maximum of 65,535 parameters per query; and it is pg-driver-only. Sequel.skip_pg_auto_param and no_auto_parameterize exist as narrow escape hatches for a single expression.
Prepared statements — default on, switchable. Sequel spells them ds.prepare(:select, :name) and ps.call(n: "Jim"), with :$name placeholders (prepared statements). Magik prepares the statements a declaration implies — a scope, an api resource's index/show, the model's primary-key lookup — at boot, because Sequel's own documentation warns: "Creating a prepared statement uses Object#extend, which can hurt performance. For high performance applications, it's recommended to create all of your prepared statements upon application initialization, and not to create prepared statements dynamically at runtime."
The switch exists for exactly one reason: a deployment behind a connection pooler in transaction mode. database prepared_statements: false.
2.3 Timeouts — baked in that they exist, configurable in what they are
Almost nobody sets these, and an unbounded query is how one request takes down a database for everyone. All four are milliseconds when no unit is given and all default to 0, meaning disabled (runtime-config-client):
| Setting | Postgres' own wording | Since |
|---|---|---|
statement_timeout |
"Abort any statement that takes more than the specified amount of time." | — |
lock_timeout |
"Abort any statement that waits longer than the specified amount of time while attempting to acquire a lock…" | — |
idle_in_transaction_session_timeout |
"Terminate any session that has been idle … within an open transaction for longer than the specified amount of time." | 9.6 |
idle_session_timeout |
"Terminate any session that has been idle … but not within an open transaction, for longer than the specified amount of time." | 14 |
idle_in_transaction_session_timeout is the one people miss. A session idle inside a transaction holds its locks and pins the oldest visible snapshot, so VACUUM cannot reclaim anything newer — one wedged request bloats the table it touched and every table behind it.
Three profiles, because one number is wrong for three process roles. This is the part that is genuinely non-obvious:
| Role | statement_timeout |
lock_timeout |
idle_in_transaction_session_timeout |
Why |
|---|---|---|---|---|
web (magik server) |
short — a request that cannot answer in seconds should fail, not queue | short | short | the user is waiting; a timeout is a better answer than a held connection |
worker (magik worker) |
long, or per-job | short | short | a batch job legitimately runs for minutes. The web number would kill it |
migration (magik migrate) |
long — an index build takes as long as it takes | very short, with retry | short | a DDL statement waiting on a lock queues every later query behind it. That is the classic migration outage, and a low lock_timeout plus retry is the cure |
lock_timeout below statement_timeout is deliberate: it makes "I waited for a lock" and "I ran too long" distinguishable in the logs instead of both arriving as one code.
How they are set. Sequel offers :connect_sqls ("an array of sql strings to execute on each new connection") and :after_connect. Magik uses :connect_sqls by default. Behind a pooler in transaction mode SET is in PgBouncer's never column, so the alternative is libpq's startup-parameter route — ?options=-c%20statement_timeout%3D5000 on the connection URL (libpq-connect) — which Magik emits instead when a pooler is declared.
Switchable: the values, per role, in App.define. Not switchable: that all four have a value. 0 is not an accepted setting.
2.4 Select the columns, not the table — baked in, widened at the call site
The framework knows what a screen renders and what an api resource serialises, because both are declarations. So the generated query projects exactly those columns.
Why SELECT * costs more than it looks |
|
|---|---|
| Transfer | every text column on the row crosses the socket whether or not anything reads it |
| TOAST | a large value stored out-of-line is fetched and de-compressed to be discarded |
| Index-only scans | a query that selects only indexed columns can be answered from the index. SELECT * never can |
The escape hatch is a widening at the call site, not a global flag: .select_all or an explicit select: list. This is not a violation of the swap points' no call-site choice rule — that rule governs which backend is named, not how a query is shaped.
2.5 Writes: bulk, not loops — baked in where the framework issues the write
create :Order in a loop is N round trips. Sequel's Dataset#import "can be used to efficiently insert a large number of records into a table in a single query if the database supports it", with :slice / :commit_every to bound transaction size (Sequel::Dataset). Where Magik generates the write — a job processing a batch, a seed, an import action, a generated factory building fixtures for a test — it uses import/multi_insert with a slice, never a loop of inserts.
For app code, the DSL exposes the bulk form and magik check --scale reports a write inside a loop (§3, §11). It is a warning, because a legitimate counter-example exists: N writes that must each fire their own audit entry or ledger post.
2.6 Counter caches — opt-in
COUNT(*) is not cheap in Postgres and the project says so plainly. The PostgreSQL wiki's Slow Counting page: "The fact that multiple transactions can see different states of the data means that there can be no straightforward way for COUNT(*) to summarize data across the whole table. PostgreSQL must walk through all rows to determine visibility."
An index-only scan helps but does not make it constant: index entries carry no visibility information, so the scan consults the visibility map, whose bits are set by VACUUM — "An index-only scan, after finding a candidate index entry, checks the visibility map bit for the corresponding heap page. If it's set, the row is known visible" (index-only scans). On a table that is being written to, those bits go stale and the scan falls back to the heap. Even at best it reads every matching index entry, so a per-tenant count stays linear in that tenant's rows.
So a data_table showing "1,248 orders" for a tenant with two million rows is a full scan on every page load.
model :Customer do
counts :orders # maintains orders_count in the same transaction
end
Opt-in, and the costs are stated at the point of declaring it: the counter column is a single row updated by every insert and delete of a child, so concurrent writes to one parent serialise on it — a hot-row contention trade, bought deliberately. Magik generates a reconciliation job and a magik check rule that compares the cached value against the real count in CI, because a counter cache that has drifted is worse than no counter cache.
The better answer is often to not need the number. Cursor pagination (§3) does not require a total count, which is one of the places the pagination decision pays for itself.
2.7 Automatic indexes — baked in
A missing index on tenant_id is a full scan on every query in the app. Nobody should have to ask for it.
What Postgres does on its own, and what it does not:
| Constraint | Index created? | Postgres' own wording |
|---|---|---|
PRIMARY KEY |
yes | "Adding a primary key will automatically create a unique B-tree index on the column or group of columns listed in the primary key" |
UNIQUE |
yes | "Adding a unique constraint will automatically create a unique B-tree index on the column or group of columns listed in the constraint" |
FOREIGN KEY |
no | "…it is often a good idea to index the referencing columns too. Because this is not always needed, and there are many choices available on how to index, the declaration of a foreign key constraint does not automatically create an index on the referencing columns" |
That third row is the gap every ORM leaves open and every large table eventually falls into. magik generate migration --from-models closes it:
| Declaration | Index emitted | Why |
|---|---|---|
every model (tenant_id is injected) |
(tenant_id) — and it is the leading column of every other index on the table |
see below |
belongs_to :customer |
(tenant_id, customer_id) |
Postgres creates none, and both the join and the parent-side delete scan without it |
field :reference, unique: true |
UNIQUE (tenant_id, reference) |
not UNIQUE (reference) |
a scope filtering on status and ordering by placed_at |
(tenant_id, status, placed_at) |
the shape the scope actually issues |
The unique-constraint row is a correctness decision wearing performance clothes. In a multi-tenant app, UNIQUE (reference) means one tenant's invoice number blocks another tenant's. It is unrecoverable once two tenants have collided, which is exactly the "reversal is impossible" test — so it is baked in with no option.
Why leading with tenant_id and not a separate index on it. Postgres: "A multicolumn B-tree index can be used with query conditions that involve any subset of the index's columns, but the index is most efficient when there are constraints on the leading (leftmost) columns. The exact rule is that equality constraints on leading columns, plus any inequality constraints on the first column that does not have an equality constraint, will always be used to limit the portion of the index that is scanned" (multicolumn indexes). For WHERE tenant_id = $1 ORDER BY placed_at DESC LIMIT 20, a (tenant_id, placed_at) index is walked as one contiguous, already-sorted range and stopped after twenty entries. A bare (tenant_id) index reads every one of that tenant's entries, visits the heap for each, and sorts.
The same page's restraint is inherited too — "Multicolumn indexes should be used sparingly … Indexes with more than three columns are unlikely to be helpful unless the usage of the table is extremely stylized." The generator emits an index per declared access path, not per column, and magik check reports an index no declaration uses, because an unused index is pure write cost.
On a table that already has rows — CREATE INDEX CONCURRENTLY, and it is not free. Postgres: it "will build the index without taking any locks that prevent concurrent inserts, updates, or deletes on the table; whereas a standard index build locks out writes (but not reads)". The costs, in the docs' own words: "a regular CREATE INDEX command can be performed within a transaction block, but CREATE INDEX CONCURRENTLY cannot"; it "must perform two scans of the table"; and on failure it "will fail but leave behind an 'invalid' index … it will still consume update overhead" (CREATE INDEX).
Two consequences Magik owns rather than leaves to the reader:
- The generated migration is marked non-transactional. Sequel wraps migrations in a transaction by default, and a
CONCURRENTLYindex inside one fails. magik doctorprobespg_index.indisvalidand reports invalid indexes with theDROP INDEX/REINDEX … CONCURRENTLYfix, because an invalid index is silent — queries ignore it and writes still pay for it.
Not switchable. An app may add indexes; it may not ask the generator to omit the tenant index or to make a unique constraint global.
3. Pagination
Cursor (keyset) pagination is the default for api resource … index, for data_table, and for every generated list. OFFSET is opt-in.
Why OFFSET is the wrong default
Postgres states the cost itself: "The rows skipped by an OFFSET clause still have to be computed inside the server; therefore a large OFFSET might be inefficient" (LIMIT and OFFSET). Page 500 computes and discards the first 500 pages of rows. The degradation is linear and completely silent — nothing errors, nothing warns, the page just takes longer every month.
The correctness half is worse and less known. From the same page: "using different LIMIT/OFFSET values to select different subsets of a query result will give inconsistent results unless you enforce a predictable result ordering with ORDER BY." And when rows are being inserted between one page request and the next, the offset shifts under the reader — use-the-index-luke: "you'll get duplicates in case there were new rows inserted between fetching two pages", and "the idea to use the number of rows seen to skip over them later is simply wrong" (no-offset).
A paginated list that silently drops a row is a defect a user reports as "the invoice disappeared", and it is unattributable.
Why this is natural here specifically
Spec item 8 pays off in a place it was not designed for. UUIDv7 primary keys were chosen for sortability and shard-safety (../idea/00-build-spec.md, ../idea/01-thesis.md axiom 9). RFC 9562 §5.7 specifies unix_ts_ms as a "48-bit big-endian unsigned number of the Unix Epoch timestamp in milliseconds" occupying the most significant bits — so binary comparison of two v7 ids orders them by creation time (RFC 9562, May 2024).
Which means the primary key is already a valid cursor and a valid tiebreaker. Keyset pagination normally requires inventing a stable total order and bolting on a tiebreaker column; here it falls out of a decision made for a different reason.
The same RFC supplies the index-locality argument that justified the choice in the first place, worth quoting because it is the clearest statement of it anywhere: "UUID versions that are not time ordered, such as UUIDv4 … have poor database-index locality. This means that new values created in succession are not close to each other in the index; thus, they require inserts to be performed at random locations. The resulting negative performance effects on the common structures used for this (B-tree and its variants) can be dramatic" (§2.1), and "The real-world differences in this approach of index locality versus random data inserts can be one order of magnitude or more" (§6.11).
(Postgres 18 added a native uuidv7() function — "Add UUID version 7 generation function uuidv7() … This UUID value is temporally sortable" (PG18 release notes). On earlier servers Magik generates the value in Ruby. Which one is used is a magik doctor line, not a behavioural difference.)
The shape
SELECT … FROM orders
WHERE tenant_id = $1
AND (placed_at, id) < ($2, $3)
ORDER BY placed_at DESC, id DESC
LIMIT 50
Postgres supports row-constructor comparison, comparing "the row elements … left-to-right, stopping as soon as an unequal or null pair of elements is found" (row constructor comparison), and — unlike MySQL, Oracle and SQL Server — it can use a row-value predicate as an index access predicate (use-the-index-luke). The matching index is emitted by §2.7 with the same column order and the same direction; the cursor columns are NOT NULL, because a NULL on either side of a row comparison makes it unknown.
The cursor itself is opaque and signed — an encoded (sort values, id) tuple, not a leaked internal key. It is stateless, which the deployment shape requires (../ops/README.md).
The honest cost
Keyset pagination cannot do what offset pagination can: "You not only have to phrase the where clause very carefully — you also cannot fetch arbitrary pages" (use-the-index-luke). No "jump to page 500", no total page count without a separate count query (see §2.6).
For an admin screen where a human genuinely wants page 500, offset is available with a declared ceiling:
index paginate: :offset, max_page: 100
Default on, switchable — at the declaration, not globally, because it is a per-list property. magik check --scale reports an offset paginator with no max_page: (proposed code MAGIK_SCALE_OFFSET_PAGINATION, warning). An index with no page ceiling at all is already refused: the wiki catalogue reserves MAGIK_API_PAGINATION_UNBOUNDED for it — see the naming note.
4. N+1 and unbounded queries
The core of this is already designed elsewhere and is not restated:
| Already covered | Where |
|---|---|
No lazy loading — an unloaded association raises MAGIK_MODEL_LAZY_ASSOCIATION rather than issuing a silent query |
../../wiki/Models.md, ../idea/05-limits.md |
N+1, slow query, unscoped query and missing-index detection, in the dev trace and in magik check --scale |
06-observability.md |
| Why all four are warnings rather than boot failures | ../idea/03-guardrails.md |
MAGIK_MODEL_LAZY_ASSOCIATION is the strongest performance default in the framework and it is not on this page's classification table because it is a guardrail, not a tuning knob. It is worth naming why it works: it converts the most common performance bug in Ruby from an invisible one into a stack trace at the line that caused it.
What is missing, and belongs in --scale
Three detections the observability page does not name, each with a legitimate counter-example — so all three are warning:
| Detection | Signal | Proposed code |
|---|---|---|
| Unbounded result set | a query reaching a render or a serializer with no limit and no paginator. The dev database has 40 rows; production has 4,000,000 |
MAGIK_SCALE_UNBOUNDED_QUERY |
| A query inside a loop | a query site lexically inside an iteration over a dataset — the N+1's sibling that eager loading does not fix, because it is a different query per row, not an unloaded association | MAGIK_SCALE_QUERY_IN_LOOP |
| A query with no usable index | a declared filter or sort with no index whose leading columns match it. Detectable statically from the declaration and the schema, without a database | MAGIK_SCALE_MISSING_INDEX |
All three are proposed names, not registered codes — adding them is a change to 03-error-codes.md and to the owning subsystem's errors.rb, per 00-conventions.md.
The third one is the interesting one, because it is the only one of the four in 06-observability.md that can be answered before the query runs. "Missing index" as that page frames it is a runtime observation ("a scan over a table above a row threshold"); as a static rule it is a comparison between the access paths the declarations imply and the indexes the migrations created — which is a magik check rule with no database and no traffic, and therefore one an agent can run mid-edit.
Prior art worth mining rather than reinventing. Sequel ships pg_auto_parameterize_duplicate_query_detection, which raises DuplicateQueries when "the same query is executed more than once with the same call stack", with :warn and :handler options, and describes itself as "designed mostly to catch duplicate query issues (e.g. N+1 queries) during testing". Its notion of duplicate is same query text; the observability page's is same query shape, differing only in a bound parameter — the second is what an N+1 actually looks like, so the extension is a starting point rather than the answer.
5. HTTP
5.1 Compression — default on, switchable
Puma does not compress responses. It is an HTTP server, not a middleware stack, and compression is not among its responsibilities. So compression is Rack::Deflater in Magik's middleware stack, on by default for text responses.
Rack::Deflater's own documented option matters here: :sync "determines if the stream is going to be flushed after every chunk. Flushing after every chunk reduces latency for time-sensitive streaming applications, but hurts compression and throughput. Defaults to true" (deflater.rb). Magik sets sync: false for ordinary buffered responses and leaves it true for streamed ones, because the default is tuned for streaming and most responses are not streams.
Two honest caveats:
| Caveat | |
|---|---|
| Small fragments | htmx swaps are often a few hundred bytes. Below a size floor, gzip costs CPU and a few bytes of framing to save nothing. Magik applies a minimum size. htmx publishes no guidance on compression — this is Magik's position, not an htmx recommendation |
| BREACH | compressing a response that contains a secret (a CSRF token) alongside attacker-influenced reflected content leaks the secret through response length. The mitigation is not to compress such responses, or to mask the token per response. Magik masks; the switch exists for operators who would rather not compress authenticated HTML at all |
5.2 Conditional requests — default on, switchable, and honest about what they save
Rack::ConditionalGet outside Rack::ETag (that order, or ETag-based 304s never happen). Rack::ETag generates a weak ETag from a SHA-256 digest, only for status 200/201, skipping responses that already carry etag or last-modified — and it buffers the body into an array to digest it (etag.rb).
That last detail is why the honest framing matters: an ETag on a dynamically rendered fragment saves the transfer, not the render. The page was fully built before the digest could be computed. To save the work, you need the fragment cache (§6), and the two are different tools. A performance document that lists ETags next to caching without saying this is the reason people are surprised their server load did not move.
5.3 Fragments, htmx, and Vary — baked in
htmx documents this precisely, and it is the single most important HTTP-caching fact for a server-rendered htmx app:
"Be mindful that if your server can render different content for the same URL depending on some other headers, you need to use the
Varyresponse HTTP header. For example, if your server renders the full HTML when theHX-Requestheader is missing or false, and it renders a fragment of that HTML whenHX-Request: true, you need to addVary: HX-Request." — htmx docs, Caching
That is exactly Magik's rendering model: a screen path answers a browser navigation with a page and an htmx request with a fragment. htmx sends HX-Request: true on every AJAX request (reference), and Vary: HX-Request is a cheap vary key — two values, unlike User-Agent, whose "very large number of variations … drastically reduces the chance that the cache will be reused" (MDN caching guide).
So the emitted response carries Vary: HX-Request (and HX-Boosted, since hx-boost responses are near-full pages at the same URL). htmx also states, in the same section: "Always disable htmx.config.historyRestoreAsHxRequest so that these history full HTML requests are not cached with partial fragment responses" — that config value defaults to true, so Magik's generated htmx configuration sets it to false. And: "if your server can render different content for the same URL … the server needs to generate a different ETag for each content", so the fragment/page distinction is part of the ETag input, not an afterthought.
Not switchable. Getting this wrong serves a bare <tr> to someone who typed the URL, or a whole page into a table cell — a cache-poisoning-shaped bug that survives a deploy because it is in the browser's cache.
Two more htmx facts with defaults attached:
| htmx behaviour | Magik's default |
|---|---|
| "If you push a URL into the history, you must be able to navigate to that URL and get a full page back!" (htmx history) | the router guarantees it: a screen path always answers a non-htmx GET with a full page. It is a routing invariant, not an app responsibility |
htmx snapshots the DOM into localStorage for history restoration (historyCacheSize default 10); hx-history="false" prevents "sensitive data entering the localStorage cache, which can be important for shared-use / public computers" |
tenant-scoped screens emit hx-history="false". Multi-tenant data in localStorage on a shared machine is a leak, not a caching trade |
5.4 Cache-Control — private is baked in, everything else is configurable
MDN on public: it "indicates that the response can be stored in a shared cache", and it will cause responses to authenticated requests to be stored in a shared cache (Cache-Control).
In a multi-tenant app, a public cache directive on a tenant-scoped response is a cross-tenant data leak — one CDN edge, two tenants, one cached page. So:
| Response | Directive | Tier |
|---|---|---|
| anything rendered from a tenant-scoped query | private, no-store by default; private, max-age=… where a declaration opts in |
baked in that it is never public |
| a fingerprinted static asset | public, max-age=31536000, immutable |
default on |
an api response |
private, no-store |
switchable per resource |
Proposed, not in the catalogue: MAGIK_RENDER_CACHE_PUBLIC, severity: :error, at boot, for a declaration asking for a shared-cacheable tenant-scoped response.
5.5 Assets — default on
Content-hashed filenames plus Cache-Control: public, max-age=31536000, immutable — MDN's documented pattern, with its example headers given verbatim on the caching guide. htmx itself (~14kb, ../idea/05-limits.md) and the generated theme CSS are the whole asset list; there is no bundler, so fingerprinting is a hash and a filename rather than a build pipeline.
MDN's own realism is worth carrying: "the cache removes old entries when new entries are saved, the probability that a stored response still exists after one week is not that high — even if max-age is set to 1 week."
5.6 Connections
Puma speaks HTTP/1.1 only. There is no HTTP/2 and no HTTP/3 from the app process, so h2 and h3 — if wanted — terminate at the load balancer or reverse proxy in front of it, which is the topology ../ops/README.md already describes. That is a real cost and it is stated rather than glossed.
Keep-alive is the operative concern: a held-open connection occupies a pool thread while it is being served, and a file descriptor for as long as it is held. That is what makes the realtime connection ceiling an open question rather than an arithmetic one, and until it is measured (12-runtime-verification.md) this page states no ceiling. The tuning levers are Puma's max_threads and ulimit -n, and neither has a recommended value here, because none has been derived from a measurement.
Static assets are served from the app process in development and expected to come from a CDN or a reverse proxy in production. Puma can serve files, but a Ruby thread copying bytes is a thread not serving a request. That is Magik's stance, not an upstream recommendation.
6. Caching — and a gap in the spec
The gap
Spec item 11 names cache backend as a required swap point, ../idea/04-swap-points.md gives it a row (default: in-process memory) and states the app-facing call as cache.fetch, and ../idea/02-dsl-surface.md shows use :cache, :memory in App.define.
There is no caching construct in the DSL. The seam exists with no grammar on top of it, which is the one combination the design is supposed to make impossible: a backend nobody can address from a declaration. wiki/Actions.md already refers in passing to "the cache it invalidates" — a behaviour with no page defining it.
This is flagged, not filled. Adding a construct is a spec change and belongs in ../idea/00-build-spec.md first, in its own commit, with the argument (00-conventions.md).
The minimum surface, if it is added
Three things, and deliberately not a fourth:
| # | Surface | Tier | Why this and not more |
|---|---|---|---|
| 1 | cache.fetch(key, ttl:) { … } — the seam's runtime API, already implied by the swap-points page |
default on (the seam), the call is opt-in | the narrowest thing every backend can honour: get, set with TTL, delete, and a namespace clear. Nothing else enters the interface |
| 2 | A fragment cache on a declaration — component :OrderRow do cache_on ->(order) { order } … end |
opt-in | this is the one that saves render time rather than transfer time (§5.2), and it is the one an agent would never write unprompted |
| 3 | A request-scoped memo — one store per request, discarded when the request ends | baked in as a mechanism, opt-in per use | the same lookup twice in one render is common and cheap to fix. It is not an identity map: it caches explicit calls, not every row loaded, because an automatic identity map means a write in the middle of a request is invisible to the read after it |
The invalidation story is generational, not manual — baked in. The cache key is derived, never hand-written:
tenant_id · declaration name · declaration digest · record id · record updated_at
| Component | Why it is in the key |
|---|---|
tenant_id |
mandatory. A cache key with no tenant is a cross-tenant read. Proposed code MAGIK_CORE_CACHE_KEY_UNSCOPED, severity: :error |
| declaration digest | changing a component's body changes its key, so a deploy invalidates exactly the fragments whose markup changed and nothing else. The registry already knows the declaration's source (08-dev-loop.md) |
updated_at |
a write produces a new key rather than deleting an old one. Nothing has to remember to invalidate |
Explicit cache.delete remains available and is the escape hatch. It is not the default because "remember to invalidate" is precisely the discipline that fails — and it fails silently, as a stale page.
The default backend is wrong for this, and the doc has to say so
The cache seam's default is in-process memory, chosen so a small app needs no infrastructure. A fragment cache on that backend, across the stateless fleet of ../ops/README.md, means:
- N app processes, N independent caches, N first-request misses;
- no cross-process invalidation, so an explicit
cache.deletereaches one process; - everything cold after every deploy.
None of that is a correctness violation — a cache is not state, and the stateless guardrail (MAGIK_RENDER_SCREEN_STATEFUL) is about request state, not memoized output. But it does mean declaring a fragment cache while running more than one process on the memory backend is close to pointless, and a framework that lets someone do that without a word has failed at the thing this page is about. That should be a boot warning naming the seam and the fix (use :cache, :redis).
7. Runtime
7.1 JIT warmup is a real trade, in both directions
TruffleRuby's deployment guide says it plainly, and the wording is worth having verbatim:
"If you are running a short-running program you probably want the default, native, configuration. If you are running a long-running program and want the highest possible performance you probably want the JVM configuration, by using
--jvm.""However to reach this peak performance you need to warm-up TruffleRuby, as you do with most heavily optimising virtual machines. This is done by running the application under load for a period of time."
The README's comparison: Native starts "about as fast as MRI startup" and reaches peak "faster", with "good" peak performance; JVM starts "slower", warms "slower", and has "best" peak performance (README).
What that costs Magik, honestly:
| Workload | The problem | Magik's position |
|---|---|---|
magik server |
the first requests after a deploy run cold. On a rolling deploy that is a latency cliff that reads like load, and autoscaling that watches p95 will mistake warmup for traffic | JVM configuration; warm before the readiness probe passes, so the load balancer does not send traffic to a cold process (../ops/README.md). Readiness already gates on config, database and guardrails — warmup joins that list |
magik worker |
long-lived, so it warms up. But a worker that starts, drains a queue for nine seconds and exits never does | long-lived worker processes, not one-shot invocations |
magik check, magik generate, magik test |
the loop an agent runs dozens of times an hour. A runtime that is fastest after sixty seconds of load is the wrong runtime for a command that should take one second | CRuby locally. This is why the repo already supports CRuby ≥ 3.2 "for tooling and development" — it is a performance decision, not only a convenience one |
--engine.Mode=latency exists and Truffle documents it as configuring "the execution mode of the engine … 'latency' and 'throughput'. The default value balances between the two" (Truffle options). A TruffleRuby issue reports it being substantially better for a test-suite workload (#1985) — an anecdotal data point in an issue thread, not a published benchmark. It is a candidate for the CLI profile and it is untested here.
Auxiliary engine caching (--engine.CacheStore), which persists compiled code across runs and would be the real answer for short-lived processes, is Oracle GraalVM only — "This feature is only available in Oracle GraalVM. In GraalVM Community Edition, these options are not available" (Auxiliary Engine Caching). Whether TruffleRuby is a supported consumer of it is unverified. A default that requires a specific commercial distribution is not a default Magik can ship.
7.2 The concurrency model
Concurrency is real, parallel OS threads. One request, one thread. One job, one worker thread. One test file group, one worker thread. There is no second mechanism anywhere in the framework.
That is a legitimate design only because of one property of the production runtime, and it is measured rather than assumed:
| TruffleRuby has no GVL | its README: it "does not have a global interpreter lock and runs both Ruby code and thread-safe native extensions in parallel" (README) |
| Threads there are genuinely parallel | measured on a warmed workload a JIT cannot fold away: ruby scripts/probes/runtime.rb, several times faster on four threads than on one, where CRuby is not faster at all. Cite the probe, never the ratio — it moves run to run with machine load, and the results table lives in 12-runtime-verification.md |
| Parallelism survives the IO path | pg releases the runtime lock while a query is in flight: eight threads each issuing a one-second query finish in about one second, not eight — scripts/probes/pg_concurrency.rb, every engine tested. This is the load-bearing half, because a request spends its life in IO rather than in Ruby |
| The evidence | ruby scripts/probes/runtime.rb — method, results and limits in 12-runtime-verification.md, As of 2026-08-26 |
fork is not available on TruffleRuby, which is why the process count is a container count (§2.1) and why nothing in this repo has a forked-worker mode.
The per-request rules are what make it correct — no in-process state across requests, a frozen registry after boot, no mutable globals (00-conventions.md). Under threads those stop being hygiene and become the isolation model, exactly as 04-testing-strategy.md says about tests.
What is not settled, and this page must not imply it is: there is no cheap-per-idle-connection primitive on this runtime. A connection costs a thread while it is served and a file descriptor while it is held. Realtime is many mostly-idle connections, the probe measured CPU-bound parallelism, and the two are different questions. The owed measurement is specified in 12-runtime-verification.md.
One TruffleRuby detail that matters for the database driver: "Native extensions are by default considered thread-unsafe for maximum compatibility with CRuby and use the global extension lock (unless --cexts-lock=false is used). Extensions can mark themselves as thread-safe either by using rb_ext_ractor_safe() or rb_ext_thread_safe()" (compatibility.md). A global lock around every query would erase the parallelism the runtime was chosen for, so this was the largest unverified assumption on the page — and it is now measured rather than assumed: scripts/probes/pg_concurrency.rb runs N threads, one connection each, each issuing SELECT pg_sleep(1), and the queries overlap on every engine tested (12-runtime-verification.md, As of 2026-08-26). pg releases the lock. pg is also CI-tested on TruffleRuby by its own maintainers ("Add support for TruffleRuby. It is regularly tested as part of our CI", 1.3.0), so neither the driver nor the lock is now the doubt — but the result is engine-and-version specific, and re-running the probe after a major is how it stays true.
7.3 frozen_string_literal — baked in
# frozen_string_literal: true on the first line of every file, framework and generated app alike. It is already the rule (00-conventions.md) and it is enforced by RuboCop, so it appears here only to be classified.
The honest scope of the claim: Ruby has not made it the default — 3.4 introduced "chilled" string literals that warn on mutation rather than raise (Feature #20205), and Ruby 4.0 did not flip it. TruffleRuby publishes no guidance that the magic comment helps or hurts there; what is on the record is that TruffleRuby "replace[s] a call of -"string" with frozen string literal at parse time" (CHANGELOG). So the argument for it here is primarily correctness and consistency — a shared mutable literal is a bug on any engine — and the allocation saving is a secondary claim nobody in this repo has measured.
7.4 Memory
Nothing to tune with an environment variable (see what does not apply). TruffleRuby's own footprint guidance is the configuration choice itself: "you may find that the simpler garbage collector and current lack of compressed ordinary object pointers (OOPS) actually increases your memory footprint and you will be better off with the JVM configuration using --jvm to reduce memory use" (deploying.md). Which configuration a Magik app should run is a deployment decision, reported by magik doctor, and it is one of the few places where the answer is genuinely "measure your app".
The framework-side memory decisions are structural rather than tuned: a frozen registry shared across workers, factories that build the minimum valid row (04-testing-strategy.md), and projections that do not load columns nothing reads (§2.4).
8. Jobs
8.1 Payloads carry ids — baked in
SettleBatch.enqueue(order_id: order.id) # yes
SettleBatch.enqueue(order: order) # refused
This is a correctness argument before it is a performance one. A job payload holding a serialized record is a snapshot of the row as it was at enqueue time. The job runs later — after a retry, after a queue backlog, after a deploy — and acts on a version of the record that may no longer exist. The bug it produces is a refund issued against a stale amount, and it is unattributable because the code reads correctly.
The performance argument is real too: the payload is a column in the queue table, written, read, and indexed. A serialized object graph is orders of magnitude larger than a UUID, and the redaction list cannot be applied to fields nobody declared (07-configuration-and-secrets.md).
Not switchable. Proposed code MAGIK_JOBS_PAYLOAD_NOT_SERIALIZABLE, at boot where the argument shape is declared and at enqueue otherwise.
The corollary the framework owes back: a job whose record was deleted between enqueue and run must fail with a code that says so, not a NoMethodError on nil.
8.2 Retries jitter by default — baked in
The DSL already spells retries as retries times: 5, backoff: :exponential (../idea/02-dsl-surface.md). In Magik, :exponential means exponential with jitter, and there is no un-jittered setting.
The reason, from the source of the term — Marc Brooker, Exponential Backoff And Jitter, AWS Architecture Blog: without jitter "there are still clusters of calls. Instead of reducing the number of clients competing in every round, we've just introduced times when no client is competing." The article's Full Jitter form is sleep = random(0, min(cap, base * 2 ** attempt)).
The failure mode is specific and common: a downstream provider has a thirty-second outage, ten thousand jobs fail in that window, and every one of them retries at exactly t + 2, then exactly t + 4, then exactly t + 8 — re-creating the original spike at each step, against a service that is still recovering. Un-jittered exponential backoff does not spread load; it quantises it.
There is no legitimate reason to want synchronized retries, so there is no flag. times: and cap: are configuration.
8.3 Scheduled jobs spread across the tenant set — baked in
schedule cron: "0 3 * * *" on a job that runs per tenant means every tenant's job fires at 03:00:00 — a thundering herd the app authored itself, and the second-largest self-inflicted load spike after retry storms.
Magik derives a deterministic offset from tenant_id within the schedule's window, so tenant A always runs at 03:04 and tenant B always at 03:41. Deterministic rather than random, because a job that moves every night is a job nobody can correlate with anything in a log.
Not switchable. A job that genuinely must run at exactly 03:00 for every tenant is a job with a different schedule declaration, not a flag on this one.
8.4 Batching — opt-in, with the trade stated
One job per row gives per-row retry granularity and per-row failure isolation, and costs a queue row, a lock, and a transaction per item. One job per batch inverts every one of those.
The framework does not choose. It makes both expressible and states the trade at the point of choosing, which is the difference between a default and an opinion nobody can act on:
| Shape | Buys | Costs |
|---|---|---|
| job per row | a failure affects one row; retry is precise | N queue rows, N transactions, N sets of overhead |
| job per batch | one row, one transaction, bulk writes (§2.5) | one poisoned row can fail the batch. Needs a per-item error collection and a resume point |
idempotent_by is already in the DSL and is the deduplication half of this — one spelling, shared with action, and deliberately not unique:, which means a database uniqueness constraint and nothing else (D4).
8.5 Queue mechanics — baked in
The default backend is a Postgres queue, Que-style (../idea/04-swap-points.md), and two of its properties are performance properties:
| Property | Why it matters |
|---|---|
| Transactional enqueue | a rolled-back transaction enqueues nothing. No compensating logic, no orphaned job, and no second datastore to keep consistent |
LISTEN/NOTIFY wakeup plus advisory locks, not polling |
a poller either burns queries at idle or adds latency to every job. The wakeup path costs neither — at the price of one held connection per listener, which is in the §2.1 arithmetic |
The worker's statement_timeout profile is the one from §2.3, and it is the reason those are per-role rather than global.
9. Where the defaults come from
Every default above is one of three things, and it is worth being explicit about which, because "the framework does it for you" is only trustworthy if it is inspectable:
| Source | Example |
|---|---|
| A declaration the framework can read | the index set (§2.7), the projected columns (§2.4), the prepared statements (§2.2), the cache key digest (§6) — all derived from model/screen/api declarations by the same registry that powers magik explain (06-observability.md) |
| A configuration value with a non-zero default | the pool size, the timeouts, the compression floor. Printed by magik doctor, never silently assumed |
| A refusal | no lazy loading, no unbounded index, no public cache on tenant data, no serialized record in a job payload. These are guardrails and they live in ../idea/03-guardrails.md |
magik explain query Order.recent shows the SQL a scope compiles to including the injected tenant predicate; the same command is what shows which index the generator emitted and which one the planner chose. A framework that writes part of your SQL has to be able to show you all of it.
10. What is deliberately not here
| Not doing | Why |
|---|---|
| Read replicas / read-write splitting | it changes read-after-write semantics for every action in the app. That is a design decision with a guardrail attached, not a performance default, and it is not in the spec |
| Sharding | spec item 8 makes it possible later (UUIDv7, tenant_id everywhere) and magik check --scale protects the property. Nothing implements it |
| Query result caching at the ORM layer | the invalidation surface is every write in the app. The fragment cache (§6) caches something the framework can key correctly; a query cache is not |
| Client-side anything | no offline cache, no client store, no heavy client compute — permanent refusals (../idea/05-limits.md), not a performance frontier |
| Micro-optimising Ruby | the wins on this page are one or two orders of magnitude apart from method-call overhead. A missing index costs more than every frozen_string_literal in the codebase combined |
11. Measurement
A performance document with no measurement is folklore. No number on this page is a measurement, and the rule for adding one is: the command that produced it, and the date it was run.
What already reports
| Surface | Reports | Defined in |
|---|---|---|
magik check --scale [--json] |
unscoped queries, N+1, missing indexes, slow-query sites — plus the three proposed in §4 | 06-observability.md |
| the request trace | screen → action → model → query → job → broadcast, per-node timings, the dominant node marked, --json as a walkable tree |
same |
| structured logs | one event per request/action/query/job, with duration_ms and correlation_id on every one; per-query logging is threshold-gated in production |
same |
magik doctor |
Ruby engine and configuration, Postgres version, max_connections and the pool arithmetic (§2.1), extensions, invalid indexes (§2.7), which JSON codec resolved (§1) |
same, extended by this page |
magik test --report=timing --json |
total wall clock, per-worker load, slowest N tests | 04-testing-strategy.md |
The targets, with the commands that will test them
The spec sets one performance target. It is a target:
Full test suite of 1,000 tests: parallel, all cores, <10s target —
../idea/00-build-spec.md
magik test --workers=auto --report=timing --json
Which runtime that target is measured on is currently unstated, and §7 is the reason it matters: a warmup-sensitive runtime and a ten-second budget are in tension, and the answer may be that the suite runs on CRuby while the server runs on TruffleRuby. Whoever measures it first must say which engine, which configuration (--native or --jvm), and on what hardware — a bare "9.4s" is not a result.
Everything else on this page has a command rather than a number:
| Question | Command |
|---|---|
| Which JSON codec is faster here? | the :json seam conformance suite, run against every registered codec on TruffleRuby in CI (../idea/04-swap-points.md) |
| Does this pool size hold under load? | magik doctor for the arithmetic; the duration_ms distribution of event=query for the queueing |
| Did that index get used? | magik explain query <Scope> |
| Where did this request go? | the request trace, --json |
| Is anything unscoped, unbounded, or unindexed? | magik check --scale --json |
| Do threads actually run in parallel on TruffleRuby? | ruby scripts/probes/runtime.rb on each engine — answered 2026-08-26, 12-runtime-verification.md |
Do concurrent queries overlap, or does pg hold the runtime lock? |
DATABASE_URL=… ruby scripts/probes/pg_concurrency.rb — answered 2026-08-26: they overlap |
| How many idle realtime connections does one Puma process hold there? | unresolved — the measurement is specified in 12-runtime-verification.md and has not been run |
Conflicts and open questions
Named rather than smoothed over, because each is a place where a performance default meets a decision the spec already made.
1. The realtime connection ceiling on TruffleRuby is unmeasured
This is the largest open question in the performance design. A connection under Puma costs a thread while it is served and a file descriptor while it is held. Realtime (live/channel over LISTEN/NOTIFY) is many mostly-idle connections, and nobody has measured how many one process holds or at what memory.
Everything else about the concurrency model is settled by measurement (12-runtime-verification.md). This is not, and no page in this repo may imply a number.
Resolution: a load test, specified. Idle SSE/WebSocket connections per Puma process on TruffleRuby, stepped, with RSS and heartbeat latency at each step. The procedure is written out in 12-runtime-verification.md; it has not been run.
2. ~~Whether pg runs without TruffleRuby's global C-extension lock~~ — answered
Detailed in §7.2, and resolved 2026-08-26. TruffleRuby serialises native extensions behind a global lock unless they mark themselves thread-safe, and if pg did not get that lock lifted, every query in the process would serialise and the threading argument for database work would collapse — while the CPU-parallelism measurement stayed true and irrelevant.
The probe was small and it ran: N threads, one connection each, SELECT pg_sleep(1) on each. Wall clock is one query, not N — the queries overlap, so pg releases the lock (../../scripts/probes/pg_concurrency.rb, results in 12-runtime-verification.md). This row stays on the page rather than being deleted, because the answer is version-specific and re-running the probe after a pg or TruffleRuby major is what keeps it honest.
3. The default cache backend is a poor fragment-cache backend
Detailed in §6. use :cache, :memory is right for "an app with no Redis"; it is close to useless for a fragment cache across the multi-process fleet the ops page describes. Not a contradiction — a documented interaction that needs a boot warning rather than silence.
4. SET-based connection setup versus a connection pooler
§2.3 sets four Postgres timeouts through :connect_sqls. PgBouncer lists SET as never working in transaction pooling mode, and LISTEN — the default realtime and job-wakeup transport — as never working either. So the deployment that most needs a pooler is the one whose defaults it breaks. Magik's answer is the libpq options= startup-parameter route when a pooler is declared, plus documentation that transaction pooling and the Postgres realtime backend are mutually exclusive. That interaction is not currently in ../ops/README.md.
5. A code-naming drift worth fixing — resolved 2026-08-26
wiki/Error-Codes.md reserved MAGIK_PAGINATION_UNBOUNDED, which did not fit the MAGIK_<SUBSYSTEM>_<CONDITION> format from 03-error-codes.md — pagination is not a subsystem in 01-module-map.md. It is now MAGIK_API_PAGINATION_UNBOUNDED, and it was not the only one: the catalogue clean-up of that date renamed forty-nine spellings and collapsed sixteen second names for guardrails the spec had already named. The token set is now closed and ../../scripts/checks/error_codes.rb enforces it, so the next such drift fails the gate rather than waiting for a reader to notice it.
The classification, in one table
Every default on this page, sorted by the rule in The rule that sorts this page. All planned.
Baked in — not switchable
| # | Default | Reversal test it fails |
|---|---|---|
| 1 | One serialization front door; the codec mode is pinned; no mimic_JSON |
a codec named at every call site cannot be swapped later; :object mode is not interchange JSON |
| 2 | pool equals Puma's max_threads, and Sequel.current stays Thread.current |
one request, one thread, one connection. A pool that disagrees with the thread count is wrong in one direction or the other (§2.1) |
| 3 | Bound parameters everywhere (pg_auto_parameterize) |
literalised SQL is an injection surface and defeats plan reuse. No legitimate slow version |
| 4 | All four Postgres timeouts have a non-zero value | 0 means an unbounded query can hold a connection until someone notices |
| 5 | Column projection from the declaration | SELECT * is what an agent writes if the framework makes it easiest |
| 6 | An index on tenant_id, leading every index on the table |
a missing tenant index is a full scan on every query in the app |
| 7 | An index on every foreign key | Postgres creates none, and says so |
| 8 | Unique constraints are (tenant_id, …) |
a global unique on tenant data is unrecoverable once two tenants collide |
| 9 | CREATE INDEX CONCURRENTLY on non-empty tables, in a non-transactional migration |
a blocking index build on a live table is an outage |
| 10 | Bulk insert wherever the framework issues the write | N round trips where one would do, in generated code nobody reviews |
| 11 | UUIDv7 primary keys as the cursor and the tiebreaker | spec item 8. Retrofitting a key strategy is a migration nobody survives |
| 12 | Vary: HX-Request (+ HX-Boosted); historyRestoreAsHxRequest: false; a screen path always answers a full page to a non-htmx GET |
a fragment cached as a page survives the deploy that fixed it |
| 13 | Tenant-scoped responses are never public-cacheable; hx-history="false" on them |
one CDN edge, two tenants |
| 14 | tenant_id in every cache key |
a cache key with no tenant is a cross-tenant read |
| 15 | Generational cache keys (declaration digest + updated_at), not manual invalidation |
"remember to invalidate" fails silently, as a stale page |
| 16 | Job payloads are ids, never records | a serialized record is stale by the time the job runs — a correctness argument first |
| 17 | Exponential backoff is always jittered | synchronized retries recreate the spike that caused the failure |
| 18 | Per-tenant scheduled jobs get a deterministic offset | cron per tenant is a thundering herd the app authored itself |
| 19 | # frozen_string_literal: true on every file |
a shared mutable literal is a bug on any engine (00-conventions.md) |
| 20 | Redaction runs before serialization | a secret that reaches the serializer has already left |
Default on, switchable
| # | Default | Switch | Seam? |
|---|---|---|---|
| 1 | JSON codec: the json gem |
use :json, … · MAGIK_JSON_BACKEND |
yes — proposed new row for ../idea/04-swap-points.md |
| 2 | Connection pool size and timeout | database pool:, pool_timeout: |
no — configuration |
| 3 | The four timeout values, per process role | App.define, per role |
no — configuration |
| 4 | Prepared statements, built at boot | database prepared_statements: false (a pooler in transaction mode) |
no |
| 5 | Response compression (Rack::Deflater, sync: false, size floor) |
on/off, floor, content types | no |
| 6 | ETag + conditional GET | on/off | no |
| 7 | Asset fingerprinting + max-age=31536000, immutable |
on in production, off in dev | no |
| 8 | Cursor pagination | index paginate: :offset, max_page: N — per declaration |
no |
| 9 | Cache backend (existing seam; default in-process memory) | use :cache, :redis |
yes — already listed |
| 10 | Slow-query and per-query logging thresholds | configuration (06-observability.md) |
no |
| 11 | TruffleRuby configuration (--jvm vs native) per process role |
deployment | no |
Opt-in
| # | Feature | When you would want it |
|---|---|---|
| 1 | Fragment cache on a declaration | an expensive render repeated across requests — the only tool here that saves render time rather than transfer. Wants a shared cache backend to be worth declaring |
| 2 | cache.fetch in app code |
an expensive non-render computation with a natural key and a tolerable staleness window |
| 3 | Request-scoped memo | the same lookup twice in one render. Not an identity map, on purpose |
| 4 | Counter caches (counts :orders) |
a count displayed on a hot page over a table large enough that a scan hurts. Costs write contention on one row, and a reconciliation job |
| 5 | Offset pagination with max_page: |
an admin screen where a human genuinely wants page 47 |
| 6 | Job batching | high-volume homogeneous work where per-row retry granularity is not worth N queue rows |
| 7 | Additional composite indexes beyond the generated set | a query shape no declaration expresses |
| 8 | OpenTelemetry export | you have a collector. The local trace and logs are the primary tool either way (06-observability.md) |
| 9 | A connection pooler in front of Postgres | process × pool exceeds max_connections. Costs LISTEN, SET, and session advisory locks — see conflict 5 |
Adding to this page
The bar, so it stays a design document rather than a tips list:
| Test | Question |
|---|---|
| Specific to this stack | is it true on TruffleRuby + Puma + Sequel + Postgres, or is it CRuby folklore? Say which |
| Sorted by reversal | is it baked in, switchable, or opt-in — and does the answer survive the test in the rule? |
| Honest about cost | what does it make worse? A default with no stated cost has not been thought through |
| Measurable | what command re-derives it? A default nobody can verify is a preference |
| Cited or flagged | an upstream claim gets a link and an attribution; anything else gets the word unverified |
| No numbers | until something is implemented and something is measured, with the command and the date |