Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/pipeline/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,25 @@ it. Snapshot tests + toolchain tests catch drift.

## Emitter ↔ runtime contract

Ruby-family lowered equality reads select SQL from the runtime value: a
non-nil value uses `col = ?` and a bind; nil uses `col IS NULL` without a slot.
The same branch selects the fragment and reserves its running bind position,
so later predicates cannot shift out of alignment. Inline emission uses
`col = <escaped value>` or `col IS NULL`. `IS ?` is deliberately avoided:
SQLite excludes `IS` from its [partial-index non-null implication rule](https://www.sqlite.org/partialindex.html#queries_using_partial_indexes).

Each nullable predicate contributes two possible fragments. Code size stays
linear: no query variants are enumerated in the compiler. A bound query with
up to seven nullable predicates has at most 128 shapes; queries above that
budget use `prepare_uncached`, avoiding exponential growth within a long
lease. Existing per-connection cache limits still apply across query sites.
Strict-target lowering retains its existing predicates and lifecycle.

Generated Ruby-family reads use `ensure Db.finalize(stmt)` around binding,
serialization after prepare, stepping and hydration, including reload and
preloads. Text preprocessing failures also release the binder's checkout.
Cleanup therefore completes before a caller rescues within an ongoing lease.

For each target:

- **Emitter assumes** specific function names, signatures, and
Expand Down
14 changes: 14 additions & 0 deletions runtime/ruby/active_record/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ def _await_preload
nil
end

# Ruby accepts nil at these public entry points. Reject it before a
# key-typed adapter can coerce it to a real zero/empty-string key.
def self.find(id)
raise RecordNotFound, "Couldn't find #{name} without an ID" if id.nil?
result = _adapter_find_by_id(id)
raise RecordNotFound, "Couldn't find #{name} with id=#{id}" if result.nil?
result
end

def self.exists?(id)
return false if id.nil?
_adapter_exists_by_id?(id)
end

# Stateless facade — every member delegates straight to `Db`, so a
# fresh instance per call is cheap and dodges class-ivar state.
def self.connection
Expand Down
3 changes: 3 additions & 0 deletions runtime/ruby/active_record/connection.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ module ActiveRecord
def self.record_timestamps: () -> bool
def update_column: (String | Symbol name, untyped value) -> bool
def _as_json_only: (Array[Symbol] only) -> Hash[String, untyped]
# Ruby-family key guards also accept nil at public entry points.
def self.find: (Integer | String | nil id) -> Base
def self.exists?: (Integer | String | nil id) -> bool
# Ruby-family overrides of base.rb's Array / COUNT fallbacks.
def self.where: (Hash[Symbol, untyped] conditions) -> Relation
def self.all: () -> Relation
Expand Down
10 changes: 8 additions & 2 deletions runtime/ruby/db.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ module Db
def self.close: () -> void
def self.exec: (String) -> void
def self.prepare: (String) -> Integer
# Reads with too many SQL shapes bypass the cache; finalize releases them.
def self.prepare_uncached: (String) -> Integer
def self.step?: (Integer) -> bool
def self.column_int: (Integer, Integer) -> Integer
def self.column_float: (Integer, Integer) -> Float
Expand All @@ -32,9 +34,13 @@ module Db
# gate emits parameterized queries. Keys the prepared-statement cache
# on the static query shape instead of per-value. Every Db shim
# (cruby-gem + spinel-FFI) implements these.
def self.bind_int: (Integer, Integer, Integer) -> void
def self.bind_int: (Integer, Integer, Integer?) -> void
def self.bind_text: (Integer, Integer, String) -> void
def self.bind_bool: (Integer, Integer, bool) -> void
# A nullable boolean binds SQL NULL, never the false/0 representation.
def self.bind_bool: (Integer, Integer, bool?) -> void
def self.bind_int_opt: (Integer, Integer, Integer?) -> void
def self.bind_text_opt: (Integer, Integer, String?) -> void
def self.bind_bool_opt: (Integer, Integer, bool?) -> void
def self.last_insert_rowid: () -> Integer
def self.changes: () -> Integer
# Query-log capture (issue #27) — records the SQL each prepare/exec
Expand Down
84 changes: 77 additions & 7 deletions runtime/spinel/db.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ module SQL
# coercion for the -1 destructor is the spinel primitive validated in
# spinel's test/ffi_ptr_int_literal.rb.
ffi_func :sqlite3_bind_int64, [:ptr, :int, :long], :int
ffi_func :sqlite3_bind_null, [:ptr, :int], :int
ffi_func :sqlite3_bind_text, [:ptr, :int, :str, :int, :ptr], :int
# `sqlite3_column_type` reports the storage class of a column in the
# current row; 5 is SQLITE_NULL. The nullable-column reads below need
Expand Down Expand Up @@ -341,13 +342,18 @@ def cell_text(row, i)
# before the first step), and the real stmt it promoted to when the
# recorded prefix ran out (0 = still replaying).
class QcCursor
def initialize(entry)
def initialize(entry, preparable)
@entry = entry
@preparable = preparable
@pos = -1
@promoted = false
@real_ptr = nil
end

def preparable
@preparable
end

def promoted
@promoted
end
Expand Down Expand Up @@ -498,6 +504,16 @@ def prepare_cached(sql)
end
i -= 1
end
prepare_owned(sql, cached)
end

# Reads with many SQL shapes share checkout and lease cleanup with
# busy-hit transient statements.
def prepare_uncached(sql)
prepare_owned(sql, false)
end

def prepare_owned(sql, cached)
# `SQL.stmt_out` is ONE 8-byte out-buffer for the whole process (an
# `ffi_buffer`, static C storage). Under parallel OS workers two
# connections preparing at the same moment wrote their statement
Expand Down Expand Up @@ -652,11 +668,11 @@ def qc_clear
# The handle for `sql`: a replay handle on a hit, else the real stmt
# (recording its rows as they are stepped, when the cache is on).
# Returns 0 as "no replay" so `Db.prepare` can tell the two apart.
def qc_lookup(sql)
def qc_lookup(sql, preparable = true)
return 0 if !@qc_on || sql.include?("?")
e = @qc_by_sql[sql]
return 0 if e.nil?
@qc_cursors.push(QcCursor.new(e))
@qc_cursors.push(QcCursor.new(e, preparable))
@qc_cursors.length
end

Expand Down Expand Up @@ -766,7 +782,7 @@ def qc_step?(handle)
return false if e.eof
# The first consumer stopped before the end and this one wants more:
# re-run the real statement and fast-forward past what was replayed.
ptr = prepare_cached(e.qc_sql)
ptr = c.preparable ? prepare_cached(e.qc_sql) : prepare_uncached(e.qc_sql)
c.promote_to(ptr)
n = 0
while n < e.nrows
Expand Down Expand Up @@ -1531,6 +1547,27 @@ def self.prepare(sql)
ptr
end

# Bypass statement reuse for nullable shape overflow, retaining the
# separate request result cache. A partial replay must promote uncached.
# Finalize releases the transient immediately; abandoned handles close
# at lease release. Outside with_connection they remain owned until
# Db.close, so scripts must finalize explicitly after use.
def self.prepare_uncached(sql)
# Keep shared cache keys string-typed even when the compiled program
# has no transient call site from which Spinel can infer this argument.
sql = sql.to_s
conn = current_conn
handle = conn.qc_lookup(sql, false)
if handle != 0
$stderr.puts " CACHE " + sql if @sql_trace
return handle
end
record_query(sql)
ptr = conn.prepare_uncached(sql)
conn.qc_record(sql, ptr)
ptr
end
Comment thread
thomasklemm marked this conversation as resolved.

# Query-log capture — see db_cruby.rb for the full rationale (the
# test-side analog of Rails' `sql.active_record` SQLCounter; the one
# instrument that can see the includes(:assoc) N+1 `compare` is blind
Expand Down Expand Up @@ -1756,7 +1793,7 @@ def self.column_name(stmt, i)

# roundhouse#12 Path A.1: with caching on, "finalize" means rewind the
# cached stmt (reset cursor + clear any bound params) so the next call
# reuses it. Busy-hit transients are really finalized on release.
# reuses it. Busy-hit and explicit uncached reads are finalized on release.
def self.finalize(stmt)
conn = current_conn
if stmt.is_a?(Integer)
Expand All @@ -1777,20 +1814,53 @@ def self.finalize(stmt)
# reset + clear_bindings'd at its previous `finalize`, so re-binding
# here starts clean.
def self.bind_int(stmt, idx, value)
# A nullable caller must not lose nil at the typed FFI integer boundary.
bind_int_opt(stmt, idx, value)
end

def self.bind_int_opt(stmt, idx, value)
raise "Db.bind failed (21): cannot bind a replay cursor" if stmt.is_a?(Integer)
if value.nil?
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_null(stmt, idx))
else
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_int64(stmt, idx, value))
end
end

def self.bind_text_opt(stmt, idx, value)
raise "Db.bind failed (21): cannot bind a replay cursor" if stmt.is_a?(Integer)
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_int64(stmt, idx, value))
if value.nil?
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_null(stmt, idx))
else
bind_text(stmt, idx, value)
end
end

def self.bind_bool_opt(stmt, idx, value)
raise "Db.bind failed (21): cannot bind a replay cursor" if stmt.is_a?(Integer)
if value.nil?
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_null(stmt, idx))
else
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_int64(stmt, idx, value ? 1 : 0))
end
end

def self.bind_text(stmt, idx, value)
raise "Db.bind failed (21): cannot bind a replay cursor" if stmt.is_a?(Integer)
# This shim's inline writer uses TEXT regardless of Ruby encoding.
# Preserve all bytes at the FFI boundary, including embedded NULs.
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_text(stmt, idx, value, value.bytesize, -1))
end

# SQLite has no native bool — bind 0/1, matching escape_bool's inline
# form and the INTEGER affinity `t.boolean` columns get.
def self.bind_bool(stmt, idx, value)
raise "Db.bind failed (21): cannot bind a replay cursor" if stmt.is_a?(Integer)
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_int64(stmt, idx, value ? 1 : 0))
if value.nil?
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_null(stmt, idx))
else
current_conn.bind_checked(stmt, idx, SQL.sqlite3_bind_int64(stmt, idx, value ? 1 : 0))
end
end

def self.last_insert_rowid
Expand Down
54 changes: 50 additions & 4 deletions runtime/spinel/db_cruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -738,10 +738,31 @@ def self.prepare(sql)
end
handle = { stmt: stmt, row: nil, cached: cached, capture: nil, open: open }
open[stmt] = handle
handle[:capture] = { rows: [], names: stmt.columns, eof: false, sql: sql } if !qcache.nil? && !parameterized
handle
end

# Explicit uncached reads skip statement reuse, but the separate
# request result cache still applies. Partial replays already promote
# to a transient statement, which finalize closes.
def self.prepare_uncached(sql)
qcache = Fiber[:rh_qcache]
unless qcache.nil? || parameterized
handle[:capture] = { rows: [], names: stmt.columns, eof: false, sql: sql }
parameterized = sql.include?("?")
if !qcache.nil? && !parameterized && (hit = qcache[sql])
return { stmt: nil, row: nil, cached: false, replay: hit, pos: 0, sql: sql }
end
record_query(sql)
conn = current_dbh
stmt = conn.prepare(sql)
statement_handle(open_statements(conn), stmt, sql, false, !qcache.nil? && !parameterized)
end

# Transient handles use the same ownership and bounded capture contract.
# The cached path constructs its handle inline on the query hot path.
def self.statement_handle(open, stmt, sql, cached, capture_rows)
handle = { stmt: stmt, row: nil, cached: cached, capture: nil, open: open }
open[stmt] = handle
handle[:capture] = { rows: [], names: stmt.columns, eof: false, sql: sql } if capture_rows
handle
end

Expand Down Expand Up @@ -932,12 +953,37 @@ def self.bind_int(handle, idx, value)
bind_value(handle, idx, value)
end

def self.bind_int_opt(handle, idx, value)
bind_int(handle, idx, value)
end

def self.bind_text_opt(handle, idx, value)
value.nil? ? bind_value(handle, idx, nil) : bind_text(handle, idx, value)
end

def self.bind_bool_opt(handle, idx, value)
bind_value(handle, idx, value.nil? ? nil : (value ? 1 : 0))
end

def self.bind_text(handle, idx, value)
bind_value(handle, idx, value)
value = value.to_s
# Match escape_string's storage class exactly. The gem otherwise binds
# every BINARY string as BLOB and every UTF-8 string (even NUL) as TEXT;
# SQLite equality does not equate the same bytes across those classes.
if value.include?("\0") || (value.encoding == Encoding::BINARY && !value.ascii_only?)
bind_value(handle, idx, value.b)
elsif value.encoding == Encoding::BINARY
bind_value(handle, idx, value.encode(Encoding::UTF_8))
else
bind_value(handle, idx, value)
end
rescue StandardError => error
# Encoding checks/conversion can raise before bind_value is entered.
statement_failed(handle, "bind", error)
end

def self.bind_bool(handle, idx, value)
bind_value(handle, idx, value ? 1 : 0)
bind_value(handle, idx, value.nil? ? nil : (value ? 1 : 0))
end

def self.last_insert_rowid
Expand Down
Loading
Loading